codex-copilot-dx 0.9.18 → 0.9.20

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/README.md CHANGED
@@ -68,6 +68,8 @@ The dashboard does not poll in the background; refresh when needed.
68
68
  expose it to the public internet. The dashboard is not an authentication wall
69
69
  for the API. Diagnostic errors may contain sensitive upstream text.
70
70
 
71
+ Startup checks GitHub Releases, not npm, and suggests `ccdx update github` when a newer stable version is available. GitHub updates install the exact release tag; dependency downloads still use npm's configured registry.
72
+
71
73
  See the [complete command/configuration reference](https://github.com/DaleXiao/codex-copilot-dx/blob/main/docs/REFERENCE.md)
72
74
  for authentication recovery, configuration-write boundaries, advanced limits,
73
75
  logging, image setup and client compatibility details.
package/bin/cli.mjs CHANGED
@@ -307,7 +307,7 @@ async function printUpdateNotice() {
307
307
  const { latestVersion, updateAvailable } = await checkForUpdate({ currentVersion: LOCAL_VERSION });
308
308
  if (!updateAvailable) return;
309
309
  console.log(`\n ${status("warn", `Update available: ${LOCAL_VERSION} -> ${latestVersion}`)}`);
310
- console.log(" npm install -g codex-copilot-dx@latest\n");
310
+ console.log(` Run ${CLI_NAME} update github to update, then restart ${CLI_NAME}.\n`);
311
311
  } catch {
312
312
  // Never block startup on the update check.
313
313
  }
@@ -315,8 +315,10 @@ async function printUpdateNotice() {
315
315
 
316
316
  try {
317
317
  assertSafeAdapterHost(ADAPTER_HOST, process.env);
318
- void runInBackground(printUpdateNotice);
318
+ const updateNotice = runInBackground(printUpdateNotice);
319
319
  if (await reuseRunningAdapterIfAvailable()) {
320
+ // Ready is already displayed; finish the bounded check before this short-lived CLI exits.
321
+ await updateNotice;
320
322
  await LOGGING.cleanup();
321
323
  process.exit(0);
322
324
  }
package/docs/README.md CHANGED
@@ -15,6 +15,8 @@ These reports record a specific release and test environment. Their version,
15
15
  test counts, timings, live-provider observations, and limitations remain
16
16
  historical facts rather than current guarantees.
17
17
 
18
+ - [0.9.19: GitHub startup checks and stable-release updates](RELEASE_0.9.19.md)
19
+ - [0.9.20: timing evidence, real-client checks and bounded analytics reuse](RELEASE_0.9.20.md)
18
20
  - [0.9.13: model-bound Auto-review reasoning selection](RELEASE_0.9.13.md)
19
21
  - [0.9.12: GPT-6.1 Sol / low Auto-review default](RELEASE_0.9.12.md)
20
22
  - [0.9.11: bounded usage-summary cache and diagnostic redaction](RELEASE_0.9.11.md)
package/docs/REFERENCE.md CHANGED
@@ -201,7 +201,7 @@ ccdx image-status
201
201
  ccdx disable-image
202
202
  ```
203
203
 
204
- `image-status` never prints the API key and performs only a local, non-generating tool-readiness check. Disabling removes the stored provider credential, the CCDX-owned MCP block, and unmodified skill, reference, and helper files; user-modified files are preserved. Cached tool calls then fail closed. The Node helper creates a new file under the task's `output/imagegen` directory by default and reports its absolute path; it never overwrites an existing file or automatically repeats a failed generation. The image endpoint is contacted only by `enable-image` validation and by explicit `generate_image` or `edit_image` tool calls. Ordinary Responses, models, authentication, Fast, Auto-review, compaction, and image-input paths remain on their existing routes.
204
+ `image-status` never prints the API key and performs only a local, non-generating tool-readiness check. It lists generation and editing separately based on the local tools/list response; a configured model name alone does not prove editing is available. Disabling removes the stored provider credential, the CCDX-owned MCP block, and unmodified skill, reference, and helper files; user-modified files are preserved. Cached tool calls then fail closed. The Node helper creates a new file under the task's `output/imagegen` directory by default and reports its absolute path; it never overwrites an existing file or automatically repeats a failed generation. The image endpoint is contacted only by `enable-image` validation and by explicit `generate_image` or `edit_image` tool calls. Ordinary Responses, models, authentication, Fast, Auto-review, compaction, and image-input paths remain on their existing routes.
205
205
 
206
206
  Generated image downloads must use public HTTPS addresses, do not follow redirects, are DNS-checked against private and reserved addresses, and are capped at 32 MiB. Well-known NAT64 `64:ff9b::/96` addresses apply the same IPv4 public/private rules to the embedded address, without rejecting legitimate public translations. DNS resolution and downloading share one deadline and respond to cancellation; a late DNS result cannot start a cancelled download. The local MCP route accepts calls only from the same device and permits at most two concurrent image operations. Host must name loopback, the actual local socket address (including equivalent IPv6/IPv4-mapped forms), or the explicitly configured bind hostname. Missing Host remains compatible with HTTP/1.0 and in-process clients. It requires `application/json` and rejects browser `Origin` headers; native Codex and Node clients do not send an Origin. The tool supports new-image generation at `1024x1024`, `1536x1024`, or `1024x1536`.
207
207
 
@@ -230,12 +230,38 @@ ccdx update npm
230
230
  ccdx update github
231
231
  ```
232
232
 
233
- `ccdx update gh` is accepted as shorthand for the GitHub source. The npm source installs `codex-copilot-dx@latest` through the registry already configured for npm, including a company mirror. The GitHub source installs the latest commit from `DaleXiao/codex-copilot-dx` `main` and opts in to Git fetching only for that command. It does not change npm's persistent `allow-git` setting.
233
+ Normal startup checks the latest stable GitHub Release once in the background, without accessing the npm registry. The anonymous check requires no PAT or Copilot credentials, has a two-second deadline and a bounded response body, and silently skips network errors, rate limits and invalid metadata. It never falls back to npm, retries, polls, installs an update or restarts the service. When a newer release is found, it prints the versions and `ccdx update github`. On adapter reuse, Ready is displayed before waiting for any remaining check budget so the short-lived command does not lose its notification.
234
+
235
+ `ccdx update gh` is accepted as shorthand for the GitHub source. The npm source installs `codex-copilot-dx@latest` through the registry already configured for npm, including a company mirror. The GitHub source queries the latest stable published Release (ten-second deadline), validates its version tag, and installs that exact tag from `DaleXiao/codex-copilot-dx`, not `main`. Lookup failure stops before installation, without an npm fallback. It opts in to Git fetching only for that command and does not change npm's persistent `allow-git` setting.
236
+
237
+ GitHub is the package source, not a replacement for the npm installer. Dependencies such as `sharp` may still need the configured npm registry during an explicit update; use a company-approved mirror/cache where required. GitHub updates do not promise an entirely registry-free installation.
234
238
 
235
239
  If a configured npm mirror has not synchronized the current release yet, use the GitHub source. A running adapter keeps its loaded version until it is stopped and restarted.
236
240
 
237
241
  ## Diagnostics
238
242
 
243
+ `ccdx status` and the dashboard show the sample count beside each timing. First
244
+ output is observed at the gateway from generated deltas, hidden-reasoning/tool
245
+ announcements or content snapshots; a runtime-only result is marked separately.
246
+ Empty message/envelope frames, client tool results and compaction are excluded.
247
+ `first_output_sources` and each recent request's `first_output_source` describe
248
+ that evidence, not internal model decode or prefill timing. Per-token time is a
249
+ gateway estimate and can include hosted-tool and downstream delays. The new
250
+ timing classifier does not change stream forwarding or the existing output
251
+ commitment that gates compatibility retries.
252
+
253
+ The adapted Codex model catalog preserves valid multi-agent reasoning mappings.
254
+ Only a mapping no longer supported by the final advertised effort options is
255
+ repaired to the last available non-Ultra effort; no model switch or forced
256
+ highest-effort policy is introduced. Client-provided defaults, option order,
257
+ instructions and multi-agent version stay intact.
258
+
259
+ CI additionally provisions the latest official Codex on its runner and uses an
260
+ isolated app-server to verify configuration, its model list and synthetic direct
261
+ and proxied Responses message delivery. It logs the tested runtime version,
262
+ does not skip installed-version mismatches and does not install clients on user
263
+ machines or use live credentials/models.
264
+
239
265
  `ccdx doctor` also inspects installed macOS App version metadata and running
240
266
  Codex/ChatGPT App executable identities, without reading command arguments,
241
267
  changing client files or killing processes. It shows the running adapter version,
@@ -428,7 +454,7 @@ The summary covers the current log and its single rotated backup, not an unbound
428
454
 
429
455
  Usage writes are asynchronous and batched under the existing cross-process rotation lock. Pending and in-flight records share a 4096-record / 4 MiB ceiling. If either ceiling is reached, new excess records are dropped with a warning and a status counter; inference does not wait or retry. Normal records and per-record rotation boundaries are unchanged. The bounded shutdown flush remains 1.5 seconds; disk failures and shutdown deadlines can prevent persistence, so usage is diagnostic rather than a billing ledger.
430
456
 
431
- Repeated plain usage summaries in the same process reuse one bounded cache entry when both retained log files are unchanged. File identity, size, modification/change timestamps and a two-second recent-write guard control reuse; concurrent settled-file queries share one scan. The serialized summary budget is 256 KiB and at most 128 model groups; larger results remain complete but uncached. Warnings, unstable reads, metadata errors and scans with changed files are not retained. Each caller receives an independent result. Analytics record callbacks and custom-warning reads keep the original streaming path. There is no persistent index, background polling, cross-process cache or change to usage writes; opening analytics still scans the retained records.
457
+ Repeated plain usage summaries in the same process reuse one bounded cache entry when both retained log files are unchanged. File identity, size, modification/change timestamps and a two-second recent-write guard control reuse; concurrent settled-file queries share one scan. The serialized summary budget is 256 KiB and at most 128 model groups; larger results remain complete but uncached. Warnings, unstable reads, metadata errors and scans with changed files are not retained. Each caller receives an independent result. Analytics record callbacks and custom-warning reads keep the original streaming path. There is no persistent index, background polling, cross-process cache or change to usage writes; the first analytics read still scans the retained records. Dashboard analytics then reuses up to two settled aggregate snapshots within a combined 2 MiB serialized budget, keyed by file identity/version, time zone and local date. Concurrent identical reads share one scan; writes, rotation, truncation, replacement, warnings, future/undated records and oversized results prevent reuse. Returned data is cloned, and custom callbacks/warnings keep the uncached path.
432
458
 
433
459
  Diagnostic redaction removes Basic authorization payloads and URL userinfo, and consumes escaped quotes inside sensitive JSON fields. Ordinary Basic-related prose, safe host/path information and error codes are retained. These protections apply to existing failure/status/debug diagnostics, not forwarded requests or upstream response bodies. They do not make arbitrary upstream prose guaranteed safe to share.
434
460
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-copilot-dx",
3
- "version": "0.9.18",
3
+ "version": "0.9.20",
4
4
  "description": "Use Codex App with GitHub Copilot",
5
5
  "author": "Dale Xiao",
6
6
  "type": "module",
package/src/cli-image.mjs CHANGED
@@ -159,7 +159,10 @@ export function syncEnabledImageSkill({ home = os.homedir(), codexPath = default
159
159
  }
160
160
 
161
161
  function printImageReadiness(readiness, output, commandName) {
162
- if (readiness.ready) output.write("Local image service: ready (generation tool verified; no image generated).\n");
162
+ if (readiness.ready) {
163
+ output.write("Local image service: ready (generation tool verified; no image generated).\n");
164
+ output.write(`Generate: available\nEdit: ${readiness.editing ? "available" : "not advertised"}\n`);
165
+ }
163
166
  else output.write(`Local image service: unavailable (${readiness.reason}). Start or restart ${commandName} to use images.\n`);
164
167
  }
165
168
 
@@ -354,9 +354,9 @@ function topicHelp(name, topic) {
354
354
  animation: `Usage:\n ${name} animation\n\nPreviews all terminal activity animations together and selects the theme used the next time the adapter starts.`,
355
355
  "enable-image": `Usage:\n ${name} enable-image\n\nInteractively configures an HTTPS image API endpoint (base URLs are also accepted) and API key, detects its supported image model and protocol, installs CCDX image guidance, and checks the local image tool for Codex App without generating an image. A blank key preserves the saved key only for the same origin; a new origin requires explicit key entry. Concurrent unrelated config edits are preserved.`,
356
356
  "disable-image": `Usage:\n ${name} disable-image\n\nDisables the local CCDX image tool and removes its stored API credential and unmodified CCDX image guidance.`,
357
- "image-status": `Usage:\n ${name} image-status\n\nShows provider configuration and probes local image-tool readiness without generating an image or exposing its API key.`,
357
+ "image-status": `Usage:\n ${name} image-status\n\nShows provider configuration and locally advertised generation/editing tools without generating an image or exposing its API key.`,
358
358
  "auto-review-model": `Usage:\n ${name} auto-review-model\n\nSelect a Responses model, then an advertised reasoning effort. Enter keeps the default/current choice; q cancels both steps without saving. Effort 0 follows the client (low when omitted). Settings apply only to Auto-review on the next request; no client patch or restart is required for adapters running 0.9.13 or newer.`,
359
- update: `Usage:\n ${name} update [npm|github]\n\nUpdates the global package from the configured npm registry or GitHub main. With no source, an interactive terminal prompts for one.`,
359
+ update: `Usage:\n ${name} update [npm|github]\n\nUpdates the global package from the configured npm registry or the latest stable GitHub release. With no source, an interactive terminal prompts for one.`,
360
360
  version: `Usage:\n ${name} --version`,
361
361
  };
362
362
  return sections[topic];
@@ -150,7 +150,7 @@ export function formatAdapterStatus({ baseUrl, data }, { commandName = "ccdx", c
150
150
  `${commandName} status`,
151
151
  adapterLine,
152
152
  status("info", `Requests: ${count(requests.completed)}/${count(requests.total)} completed, ${count(requests.active)} active, ${count(requests.status_4xx)} 4xx, ${count(requests.status_5xx)} 5xx, ${count(requests.aborted)} aborted`),
153
- status("info", `Responses stream: TTFT avg ${latency(performance.ttft_ms?.avg)} (${count(performance.ttft_ms?.samples)} samples), TPOT avg ${tpot(performance.tpot_us)}`),
153
+ status("info", `Responses stream: TTFT avg ${latency(performance.ttft_ms?.avg)} (${count(performance.ttft_ms?.samples)} samples), TPOT avg ${tpot(performance.tpot_us)} (${count(performance.tpot_us?.samples)} samples; gateway estimate)`),
154
154
  ...(terminalOutcomes ? [status("info", `Model outcomes: ${count(terminalOutcomes.completed)} completed, ${count(terminalOutcomes.incomplete)} incomplete, ${count(terminalOutcomes.failed)} failed, ${count(terminalOutcomes.cancelled)} cancelled, ${count(terminalOutcomes.unknown)} without terminal`)] : []),
155
155
  status("info", `Admission: ${count(admission.activeRequests)} active, ${count(admission.queued)} queued, ${count(admission.rejected)} rejected, ${count(admission.timedOut)} timed out, wait avg ${latency(admission.waitMsAvg)}`),
156
156
  status("info", `Memory: RSS ${mebibytes(processStats.rss_bytes)}, heap ${mebibytes(processStats.heap_used_bytes)}`),
@@ -110,6 +110,16 @@ function catalogModelForCopilot(model, copilotModels, { filterReasoning = false
110
110
  ? model.supported_reasoning_levels.filter(({ effort }) => reasoning.has(effort))
111
111
  : model.supported_reasoning_levels,
112
112
  };
113
+ // Only repair a supplied mapping that no longer names an advertised wire effort.
114
+ const levels = patched.supported_reasoning_levels;
115
+ if (Array.isArray(levels) && patched.multi_agent_reasoning_effort != null
116
+ && (!levels.some(level => level.effort === patched.multi_agent_reasoning_effort)
117
+ || (reasoning.size && !reasoning.has(patched.multi_agent_reasoning_effort)))) {
118
+ const supported = levels.filter(level => level.effort !== "ultra"
119
+ && (!reasoning.size || reasoning.has(level.effort)));
120
+ const effort = supported.at(-1)?.effort;
121
+ if (effort) patched.multi_agent_reasoning_effort = effort;
122
+ }
113
123
  // The Codex catalog describes ChatGPT service tiers. Copilot must advertise
114
124
  // an exact fast model before CCDX exposes the corresponding selector.
115
125
  if (!isEligibleCopilotGpt6(copilotModels, `${model.slug}-fast`)) {
@@ -36,6 +36,8 @@
36
36
  <div class="panel-heading"><h2 data-i18n>MODEL OUTCOMES</h2><span>RESPONSES</span></div>
37
37
  <dl class="stats"><div><dt data-i18n>Completed</dt><dd id="completed">—</dd></div><div><dt data-i18n>Incomplete</dt><dd id="incomplete">—</dd></div><div><dt data-i18n>Failed</dt><dd id="failed">—</dd></div><div><dt data-i18n>Cancelled</dt><dd id="cancelled">—</dd></div></dl>
38
38
  <p class="hint" data-i18n>HTTP 2xx != model completed.</p>
39
+ <dl class="stats timing-stats"><div><dt data-i18n>First output</dt><dd id="ttft">—</dd></div><div><dt data-i18n>Token time</dt><dd id="tpot">—</dd></div></dl>
40
+ <p class="hint" data-i18n>Gateway timing. Token time is estimated.</p>
39
41
  </article>
40
42
  <article class="panel">
41
43
  <div class="panel-heading"><h2 data-i18n>LOCAL HISTORY</h2><span data-i18n>ACTIVE LIMIT</span></div>
@@ -1,5 +1,9 @@
1
1
  (() => {
2
2
  const translations = {
3
+ "First output": ["First output", "首次输出"],
4
+ "Token time": ["Token time", "输出耗时"],
5
+ "Gateway timing. Token time is estimated.": ["Observed at the gateway. Token time is an estimate.", "网关观测时间。\n每Token耗时为估算值。"],
6
+ "{value} / {samples} samples": ["{value} / {samples} samples", "{value} / {samples}次样本"],
3
7
  "CCDX · Local dashboard": ["CCDX · Dashboard", "CCDX · 运行看板"],
4
8
  "/ LOCAL STATUS": ["/ DASHBOARD", "/ 运行看板"],
5
9
  "READING": ["READING", "读取中"],
@@ -134,3 +134,5 @@ footer { color: var(--footer); font-size: 10px; letter-spacing: .05em; padding:
134
134
  @media (max-width: 620px) { .snapshot-meta { justify-content: flex-start; margin-top: 9px; } }
135
135
  @media (max-width: 620px) { .shell { padding: 0 16px; } .topbar { min-height: 60px; } .brand-detail { display: none; } .topbar-actions { gap: 11px; } .intro { display: block; padding: 29px 0 21px; } .intro p { margin-top: 9px; } .overview, .details { grid-template-columns: 1fr; } .details .panel:last-child { grid-column: auto; } .card { min-height: 122px; } .card strong { margin-top: 14px; } .lower li { display: block; } .lower li span { display: block; margin-top: 5px; } }
136
136
  @media (max-width: 460px) { .topbar { flex-wrap: wrap; padding: 12px 0; gap: 10px; } .topbar-actions { gap: 9px; } .panel-heading { flex-wrap: wrap; } .heading-actions { gap: 10px; } .failure-detail { grid-template-columns: 1fr; gap: 3px; } .failure-detail dd { margin-bottom: 7px; } select { max-width: 210px; } }
137
+ .timing-stats { grid-template-columns: 1fr; }
138
+ .timing-stats dt { white-space: nowrap; }
@@ -147,6 +147,11 @@ function render(data) {
147
147
 
148
148
  const outcomes = data.stream_performance?.by_route?.responses?.terminal_outcomes?.totals || {};
149
149
  for (const key of ["completed", "incomplete", "failed", "cancelled"]) text(key, number(outcomes[key]));
150
+ const timing = data.stream_performance?.by_route?.responses || {};
151
+ for (const [id, metric, scale, unit] of [["ttft", timing.ttft_ms, 1, "ms"], ["tpot", timing.tpot_us, timing.tpot_us?.unit === "us" ? 1000 : 1, "ms/token"]]) {
152
+ const value = metric?.samples > 0 && finite(metric.avg) ? `${(Number(metric.avg) / scale).toFixed(1)} ${unit}` : "—";
153
+ text(id, "{value} / {samples} samples", { value, samples: number(metric?.samples) });
154
+ }
150
155
 
151
156
  const history = data.response_history || {};
152
157
  const limit = Number(history.maxBytes || data.limits?.response_history_max_bytes || 0);
@@ -13,6 +13,7 @@ import {
13
13
  } from "./terminal-animation.mjs";
14
14
  import { cacheReadTokens, summarizeUsageLogs, usageCacheHitRate } from "./usage-store.mjs";
15
15
  import { createUsageAnalytics } from "./usage-analytics.mjs";
16
+ import { analyzeUsageLogs } from "./usage-analysis.mjs";
16
17
  import { readUserSettings, terminalAnimationPreference, writeTerminalAnimationTheme } from "./user-settings.mjs";
17
18
 
18
19
  const ANIMATION_PATH = "/_ccdx/ui/animation";
@@ -111,7 +112,7 @@ export async function handleDashboardApi(req, res, pathname, {
111
112
  env = process.env,
112
113
  home = os.homedir(),
113
114
  liveModelsFn = () => fetchLiveCopilotModels({ home }),
114
- usageSummaryFn = (options) => summarizeUsageLogs(undefined, options),
115
+ usageSummaryFn,
115
116
  authStatusFn = () => authStatus({ home }),
116
117
  } = {}) {
117
118
  if (!isLoopbackAddress(req.socket?.remoteAddress)
@@ -229,8 +230,10 @@ export async function handleDashboardApi(req, res, pathname, {
229
230
  }
230
231
  }
231
232
  try {
232
- const summary = await usageSummaryFn(analytics ? { onRecord: analytics.record } : undefined);
233
- sendJson(res, 200, { source: "local_usage_log", ...usageTable(summary), ...(analytics ? { analytics: analytics.snapshot() } : {}) });
233
+ const analysis = analytics && !usageSummaryFn
234
+ ? await analyzeUsageLogs(undefined, { timeZone: query.get("time_zone") || "UTC" }) : null;
235
+ const summary = analysis?.summary || await (usageSummaryFn || (options => summarizeUsageLogs(undefined, options)))(analytics ? { onRecord: analytics.record } : undefined);
236
+ sendJson(res, 200, { source: "local_usage_log", ...usageTable(summary), ...(analytics ? { analytics: analysis?.analytics || analytics.snapshot() } : {}) });
234
237
  } catch {
235
238
  sendJson(res, 500, { error: "Could not read local usage summary" });
236
239
  }
@@ -47,7 +47,7 @@ export async function probeImageTool(codexContent, { fetchImpl = fetch, timeoutM
47
47
  await rpc(undefined, "notifications/initialized", {});
48
48
  const result = await rpc(2, "tools/list", {});
49
49
  return result?.tools?.some((tool) => tool.name === "generate_image")
50
- ? { ready: true }
50
+ ? { ready: true, generation: true, editing: result.tools.some(tool => tool.name === "edit_image") }
51
51
  : { ready: false, reason: "tool_unavailable" };
52
52
  } catch (error) {
53
53
  return { ready: false, reason: signal.aborted ? "timeout" : /^HTTP \d+$/.test(error.message) ? error.message : "unreachable" };
@@ -1,10 +1,11 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { createInterface } from "node:readline/promises";
3
+ import { fetchLatestRelease, githubReleaseVersion } from "./version.mjs";
3
4
 
4
5
  const PACKAGE_NAME = "codex-copilot-dx";
5
6
  const UPDATE_SPECS = {
6
7
  npm: `${PACKAGE_NAME}@latest`,
7
- github: "github:DaleXiao/codex-copilot-dx#main",
8
+ github: "github:DaleXiao/codex-copilot-dx",
8
9
  };
9
10
 
10
11
  export function normalizeUpdateSource(value) {
@@ -13,12 +14,16 @@ export function normalizeUpdateSource(value) {
13
14
  return Object.hasOwn(UPDATE_SPECS, source) ? source : "";
14
15
  }
15
16
 
16
- export function globalUpdateCommand(source, { platform = process.platform } = {}) {
17
+ export function globalUpdateCommand(source, { platform = process.platform, releaseTag } = {}) {
17
18
  const normalized = normalizeUpdateSource(source);
18
19
  if (!normalized) throw new Error(`Update source must be npm or github: ${source}`);
19
20
  const args = ["install", "--global"];
20
- if (normalized === "github") args.push("--allow-git=all");
21
- args.push(UPDATE_SPECS[normalized]);
21
+ if (normalized === "github") {
22
+ if (!githubReleaseVersion(releaseTag)) throw new Error("A valid stable GitHub release tag is required");
23
+ args.push("--allow-git=all", `${UPDATE_SPECS.github}#${releaseTag}`);
24
+ } else {
25
+ args.push(UPDATE_SPECS[normalized]);
26
+ }
22
27
  return {
23
28
  command: platform === "win32" ? "npm.cmd" : "npm",
24
29
  args,
@@ -52,6 +57,7 @@ export async function runPackageUpdateCommand({
52
57
  prompt,
53
58
  source,
54
59
  spawnImpl = spawn,
60
+ fetchImpl = fetch,
55
61
  } = {}) {
56
62
  let selectedSource = normalizeUpdateSource(source);
57
63
  if (source && !selectedSource) throw new Error(`Update source must be npm or github: ${source}`);
@@ -63,7 +69,7 @@ export async function runPackageUpdateCommand({
63
69
 
64
70
  output.write(`${commandName} update\n`);
65
71
  output.write(" 1. npm registry [default]\n");
66
- output.write(" 2. GitHub main\n");
72
+ output.write(" 2. GitHub release\n");
67
73
 
68
74
  let readline;
69
75
  const ask = prompt || (async (question) => {
@@ -86,8 +92,14 @@ export async function runPackageUpdateCommand({
86
92
  }
87
93
  }
88
94
 
89
- const update = globalUpdateCommand(selectedSource, { platform });
90
- const sourceLabel = selectedSource === "npm" ? "npm registry" : "GitHub main";
95
+ let release;
96
+ if (selectedSource === "github") {
97
+ output.write("Checking the latest stable GitHub release...\n");
98
+ release = await fetchLatestRelease({ fetchImpl, timeoutMs: 10000 });
99
+ if (!release) throw new Error("Could not read the latest stable GitHub release. No update was installed.");
100
+ }
101
+ const update = globalUpdateCommand(selectedSource, { platform, releaseTag: release?.tagName });
102
+ const sourceLabel = selectedSource === "npm" ? "npm registry" : `GitHub release ${release.tagName}`;
91
103
  output.write(`Updating ${PACKAGE_NAME} from ${sourceLabel}...\n`);
92
104
  const child = spawnImpl(update.command, update.args, {
93
105
  env,
@@ -40,6 +40,7 @@ import {
40
40
  import {
41
41
  isResponsesOutputEvent,
42
42
  markFirstOutput,
43
+ responsesTimingSource,
43
44
  markOutputTokens,
44
45
  markRequestObservation,
45
46
  markResponseErrorOrigin,
@@ -127,9 +128,15 @@ function inspectResponseSseEvent(state, eventName, data) {
127
128
  if (outputTokens !== undefined) markOutputTokens(outputTokens);
128
129
  const inputTokens = event.response?.usage?.input_tokens ?? event.usage?.input_tokens;
129
130
  if (inputTokens !== undefined) markRequestObservation({ input_tokens: inputTokens });
131
+ if (!state.sawTiming) {
132
+ const timingSource = responsesTimingSource(event, eventType);
133
+ if (timingSource) {
134
+ state.sawTiming = true;
135
+ markFirstOutput(timingSource);
136
+ }
137
+ }
130
138
  if (!state.sawOutput && isResponsesOutputEvent(event, eventType)) {
131
139
  state.sawOutput = true;
132
- markFirstOutput();
133
140
  }
134
141
  if (["response.function_call_arguments.delta", "response.custom_tool_call_input.delta"].includes(eventType)) {
135
142
  state.toolArgumentGuard.observe(event.output_index ?? event.item_id ?? event.call_id, event.delta);
@@ -39,6 +39,39 @@ export function isResponsesOutputEvent(event, eventType = event?.type) {
39
39
  return RESPONSES_OUTPUT_EVENT_TYPES.has(eventType) && nonEmptyString(event?.delta);
40
40
  }
41
41
 
42
+ // Timing evidence is separate from the output/compatibility-retry commitment.
43
+ export function responsesTimingSource(event, eventType = event?.type) {
44
+ if (isResponsesOutputEvent(event, eventType)) return "delta";
45
+ const itemSource = (item, announced = false) => {
46
+ if (!item || typeof item !== "object") return null;
47
+ if (["function_call_output", "custom_tool_call_output", "computer_call_output", "additional_tools", "compaction", "compaction_summary", "mcp_approval_response"].includes(item.type)) return null;
48
+ if (["program_output", "shell_call_output", "tool_search_output"].includes(item.type)) return "runtime";
49
+ if (item.caller?.type === "program") return "runtime";
50
+ if (announced && ["reasoning", "function_call", "custom_tool_call", "web_search_call", "file_search_call", "computer_call", "tool_search_call", "mcp_call", "program", "multi_agent_call", "image_generation_call"].includes(item.type)) return "announcement";
51
+ const content = item.content || item.summary;
52
+ if (Array.isArray(content) && content.some(part => nonEmptyString(part?.text) || nonEmptyString(part?.refusal))) return "snapshot";
53
+ if (nonEmptyString(item.arguments) || nonEmptyString(item.input)) return "snapshot";
54
+ return null;
55
+ };
56
+ if (eventType === "response.output_item.added" || eventType === "response.output_item.done") {
57
+ return itemSource(event.item, eventType === "response.output_item.added");
58
+ }
59
+ if (["response.content_part.added", "response.content_part.done", "response.reasoning_summary_part.added", "response.reasoning_summary_part.done"].includes(eventType)
60
+ && (nonEmptyString(event.part?.text) || nonEmptyString(event.part?.refusal))) return "snapshot";
61
+ if (eventType === "response.shell_call_output_content.delta"
62
+ && (nonEmptyString(event.delta?.stdout) || nonEmptyString(event.delta?.stderr))) return "runtime";
63
+ if (["response.completed", "response.incomplete", "response.failed"].includes(eventType)) {
64
+ let runtime = null;
65
+ for (const item of Array.isArray(event.response?.output) ? event.response.output : []) {
66
+ const source = itemSource(item);
67
+ if (source && source !== "runtime") return source;
68
+ runtime ||= source;
69
+ }
70
+ return runtime;
71
+ }
72
+ return null;
73
+ }
74
+
42
75
  export function isChatOutputDelta(delta) {
43
76
  if (!delta || typeof delta !== "object") return false;
44
77
  return nonEmptyString(delta.content)
@@ -101,6 +134,7 @@ function createRoutePerformance() {
101
134
  ttft: createHistogram(TTFT_EDGES_MS),
102
135
  tpot: createHistogram(TPOT_EDGES_US),
103
136
  requestTtft: createHistogram(TTFT_EDGES_MS),
137
+ outputSources: { delta: 0, announcement: 0, snapshot: 0, runtime: 0 },
104
138
  preparation: Object.fromEntries(PREPARATION_STAGES.map((stage) => [stage, createHistogram(PREPARATION_EDGES_MS)])),
105
139
  success_with_output: 0,
106
140
  errors_with_output: 0,
@@ -128,6 +162,9 @@ function routeSnapshot(route) {
128
162
  ttft_ms: histogramSnapshot(route.ttft, "ms"),
129
163
  tpot_us: histogramSnapshot(route.tpot, "us"),
130
164
  request_ttft_ms: histogramSnapshot(route.requestTtft, "ms"),
165
+ first_output_sources: { ...route.outputSources },
166
+ timing_boundary: "gateway_observed",
167
+ tpot_estimated: true,
131
168
  preparation_ms: Object.fromEntries(PREPARATION_STAGES.map((stage) => [stage, histogramSnapshot(route.preparation[stage], "ms")])),
132
169
  };
133
170
  }
@@ -143,6 +180,7 @@ export function createStreamPerformanceMetrics({ now = () => performance.now(),
143
180
  const requestStartedAt = now();
144
181
  let upstreamStartedAt = null;
145
182
  let firstOutputAt = null;
183
+ let firstOutputSource = null;
146
184
  let outputTokens = null;
147
185
  let failed = false;
148
186
  let finished = false;
@@ -196,8 +234,11 @@ export function createStreamPerformanceMetrics({ now = () => performance.now(),
196
234
  lastActivityAt = now();
197
235
  phase = "upstream_stream";
198
236
  },
199
- firstOutput() {
200
- if (firstOutputAt === null) firstOutputAt = now();
237
+ firstOutput(source = "delta") {
238
+ if (firstOutputAt === null) {
239
+ firstOutputAt = now();
240
+ firstOutputSource = Object.hasOwn(route.outputSources, source) ? source : "delta";
241
+ }
201
242
  },
202
243
  setOutputTokens(value) {
203
244
  const tokens = Number(value);
@@ -250,6 +291,7 @@ export function createStreamPerformanceMetrics({ now = () => performance.now(),
250
291
  failed: failed || aborted || (terminalOutcome !== null && terminalOutcome !== "completed"),
251
292
  http_status: Number.isInteger(statusCode) && statusCode >= 100 && statusCode <= 599 ? statusCode : null,
252
293
  upstream_attempts: attempts,
294
+ first_output_source: firstOutputSource,
253
295
  context: { ...context },
254
296
  timings_ms: {
255
297
  ...Object.fromEntries(PREPARATION_STAGES.map((stage) => [stage, Number(preparationMs[stage].toFixed(1))])),
@@ -260,6 +302,7 @@ export function createStreamPerformanceMetrics({ now = () => performance.now(),
260
302
  });
261
303
  if (recentRequests.length > MAX_RECENT_REQUESTS) recentRequests.shift();
262
304
  if (firstOutputAt !== null) observe(route.requestTtft, Math.max(0, firstOutputAt - requestStartedAt));
305
+ if (firstOutputSource) route.outputSources[firstOutputSource] += 1;
263
306
  if (upstreamStartedAt === null) {
264
307
  route.neutral += 1;
265
308
  return;
@@ -321,8 +364,8 @@ export function markUpstreamActivity() {
321
364
  tracker()?.upstreamActivity?.();
322
365
  }
323
366
 
324
- export function markFirstOutput() {
325
- tracker()?.firstOutput();
367
+ export function markFirstOutput(source) {
368
+ tracker()?.firstOutput(source);
326
369
  }
327
370
 
328
371
  export function markOutputTokens(value) {
@@ -0,0 +1,45 @@
1
+ import { summarizeUsageLogs, usageLogPath, usageSummaryVersion } from "./usage-store.mjs";
2
+ import { createUsageAnalytics } from "./usage-analytics.mjs";
3
+
4
+ const MAX_ENTRIES = 2;
5
+ const MAX_BYTES = 2 * 1024 * 1024;
6
+ const cache = new Map();
7
+
8
+ // Only bounded, settled-file aggregates survive. No record index or payload cache.
9
+ export async function analyzeUsageLogs(filePath = usageLogPath(), { timeZone = "UTC", now = Date.now(), warn = console.error } = {}) {
10
+ const analytics = createUsageAnalytics({ timeZone, now });
11
+ const zone = new Intl.DateTimeFormat("sv-SE", { timeZone, year: "numeric", month: "2-digit", day: "2-digit" });
12
+ const version = warn === console.error ? await usageSummaryVersion(filePath) : null;
13
+ const key = version === null ? null : `${version}\0${zone.resolvedOptions().timeZone}\0${zone.format(new Date(now))}`;
14
+ if (key && cache.has(key)) return structuredClone(await cache.get(key).promise);
15
+ if (key) {
16
+ for (const [old, entry] of cache) if (entry.version !== version) cache.delete(old);
17
+ while (cache.size >= MAX_ENTRIES) cache.delete(cache.keys().next().value);
18
+ } else cache.clear();
19
+ const entry = { version, bytes: 0, promise: null };
20
+ entry.promise = (async () => {
21
+ let warned = false;
22
+ const summary = await summarizeUsageLogs(filePath, {
23
+ onRecord: analytics.record,
24
+ warn(message) { warned = true; warn(message); },
25
+ });
26
+ const result = { summary, analytics: analytics.snapshot() };
27
+ const after = key ? await usageSummaryVersion(filePath) : null;
28
+ if (key && cache.get(key) === entry) {
29
+ entry.bytes = Buffer.byteLength(JSON.stringify(result));
30
+ if (warned || after !== version || entry.bytes > MAX_BYTES || result.analytics.undated_records > 0) cache.delete(key);
31
+ else {
32
+ let bytes = [...cache.values()].reduce((total, item) => total + item.bytes, 0);
33
+ for (const [old, item] of cache) {
34
+ if (bytes <= MAX_BYTES) break;
35
+ cache.delete(old);
36
+ bytes -= item.bytes;
37
+ }
38
+ }
39
+ }
40
+ return result;
41
+ })();
42
+ if (key) cache.set(key, entry);
43
+ try { return structuredClone(await entry.promise); }
44
+ catch (error) { if (cache.get(key) === entry) cache.delete(key); throw error; }
45
+ }
@@ -312,7 +312,7 @@ const SUMMARY_CACHE_MAX_BYTES = 256 * 1024;
312
312
  const SUMMARY_CACHE_SETTLED_MS = 2000;
313
313
  let cachedSummary = null;
314
314
 
315
- async function usageSummaryVersion(filePath) {
315
+ export async function usageSummaryVersion(filePath) {
316
316
  try {
317
317
  const files = await Promise.all([rotatedFilePath(filePath), filePath].map(async (file) => {
318
318
  try {
package/src/usage.mjs CHANGED
@@ -2,6 +2,7 @@ import { cliOutputFormat, cliOutputWidth, formatResponsiveCliTable, terminalCell
2
2
  import { cacheReadTokens, summarizeUsageLogs, usageCacheHitRate, usageLogPath } from "./usage-store.mjs";
3
3
 
4
4
  export {
5
+ usageSummaryVersion,
5
6
  usageLoggingStats,
6
7
  usageLogPath,
7
8
  usageLogMaxBytes,
package/src/version.mjs CHANGED
@@ -2,7 +2,8 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
 
5
- const REGISTRY_LATEST_URL = "https://registry.npmjs.org/codex-copilot-dx/latest";
5
+ const GITHUB_LATEST_RELEASE_URL = "https://api.github.com/repos/DaleXiao/codex-copilot-dx/releases/latest";
6
+ const MAX_RELEASE_BYTES = 256 * 1024;
6
7
 
7
8
  export function localPackageVersion() {
8
9
  const here = path.dirname(fileURLToPath(import.meta.url));
@@ -29,17 +30,62 @@ export function isVersionGreater(a, b) {
29
30
  return false;
30
31
  }
31
32
 
32
- export async function fetchLatestVersion({ fetchImpl = fetch, timeoutMs = 2000 } = {}) {
33
+ export function githubReleaseVersion(tag) {
34
+ if (typeof tag !== "string" || tag.length > 64) return null;
35
+ const match = /^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.exec(tag);
36
+ if (!match || !match.slice(1).every((part) => Number.isSafeInteger(Number(part)))) return null;
37
+ return match.slice(1).join(".");
38
+ }
39
+
40
+ async function readReleaseJson(response, signal) {
41
+ const reader = response.body?.getReader?.();
42
+ if (!reader) throw new Error("GitHub release response has no readable body");
43
+ const cancel = () => { void reader.cancel().catch(() => {}); };
44
+ signal.addEventListener("abort", cancel, { once: true });
45
+ const chunks = [];
46
+ let bytes = 0;
47
+ let complete = false;
48
+ try {
49
+ if (Number(response.headers?.get?.("content-length")) > MAX_RELEASE_BYTES) {
50
+ throw new Error("GitHub release response is too large");
51
+ }
52
+ while (!signal.aborted) {
53
+ const { done, value } = await reader.read();
54
+ if (done) {
55
+ complete = true;
56
+ break;
57
+ }
58
+ bytes += value.byteLength;
59
+ if (bytes > MAX_RELEASE_BYTES) throw new Error("GitHub release response is too large");
60
+ chunks.push(Buffer.from(value));
61
+ }
62
+ if (signal.aborted) throw new Error("GitHub release request timed out");
63
+ return JSON.parse(Buffer.concat(chunks, bytes).toString("utf8"));
64
+ } finally {
65
+ signal.removeEventListener("abort", cancel);
66
+ if (!complete) cancel();
67
+ reader.releaseLock();
68
+ }
69
+ }
70
+
71
+ export async function fetchLatestRelease({ fetchImpl = fetch, timeoutMs = 2000 } = {}) {
33
72
  const controller = new AbortController();
34
73
  const timer = setTimeout(() => controller.abort(), timeoutMs);
35
74
  try {
36
- const resp = await fetchImpl(REGISTRY_LATEST_URL, {
37
- headers: { Accept: "application/json" },
75
+ const resp = await fetchImpl(GITHUB_LATEST_RELEASE_URL, {
76
+ headers: { Accept: "application/vnd.github+json", "User-Agent": "codex-copilot-dx" },
77
+ redirect: "error",
38
78
  signal: controller.signal,
39
79
  });
40
- if (!resp.ok) return null;
41
- const data = await resp.json();
42
- return typeof data.version === "string" ? data.version : null;
80
+ if (!resp.ok) {
81
+ void resp.body?.cancel?.().catch(() => {});
82
+ return null;
83
+ }
84
+ const data = await readReleaseJson(resp, controller.signal);
85
+ const version = githubReleaseVersion(data?.tag_name);
86
+ return version && data.draft === false && data.prerelease === false
87
+ ? { version, tagName: data.tag_name }
88
+ : null;
43
89
  } catch {
44
90
  return null;
45
91
  } finally {
@@ -47,6 +93,10 @@ export async function fetchLatestVersion({ fetchImpl = fetch, timeoutMs = 2000 }
47
93
  }
48
94
  }
49
95
 
96
+ export async function fetchLatestVersion(options) {
97
+ return (await fetchLatestRelease(options))?.version || null;
98
+ }
99
+
50
100
  export async function checkForUpdate({ currentVersion = localPackageVersion(), fetchImpl, timeoutMs } = {}) {
51
101
  const latestVersion = await fetchLatestVersion({ fetchImpl, timeoutMs });
52
102
  return {