@bitkyc08/opencodex 2.52.0 → 2.53.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (236) hide show
  1. package/gui/dist/assets/index-BBOZWGB6.css +1 -0
  2. package/gui/dist/assets/index-BlO4Yl6q.js +128 -0
  3. package/gui/dist/index.html +2 -2
  4. package/native/remote-workspace-helper/Cargo.lock +130 -0
  5. package/native/remote-workspace-helper/Cargo.toml +24 -0
  6. package/native/remote-workspace-helper/src/main.rs +49 -0
  7. package/native/remote-workspace-helper/src/protocol.rs +246 -0
  8. package/native/remote-workspace-helper/src/sandbox/macos.rs +19 -0
  9. package/native/remote-workspace-helper/src/sandbox/mod.rs +77 -0
  10. package/native/remote-workspace-helper/src/sandbox/windows.rs +15 -0
  11. package/package.json +6 -1
  12. package/src/adapters/anthropic-image-normalize.ts +30 -2
  13. package/src/adapters/anthropic.ts +1 -1
  14. package/src/adapters/base.ts +8 -2
  15. package/src/adapters/cursor/cursor-errors.ts +12 -0
  16. package/src/adapters/cursor/thread-continuity.ts +93 -0
  17. package/src/adapters/cursor.ts +104 -73
  18. package/src/adapters/devin/cloud-direct/chat.ts +312 -23
  19. package/src/adapters/devin/cloud-direct/metadata.ts +31 -2
  20. package/src/adapters/devin/live-models.ts +70 -3
  21. package/src/adapters/devin.ts +281 -21
  22. package/src/adapters/google-wire-compiler.ts +14 -6
  23. package/src/adapters/google.ts +22 -8
  24. package/src/adapters/kiro/adapter.ts +316 -0
  25. package/src/adapters/kiro/conversation.ts +136 -0
  26. package/src/adapters/kiro/payload.ts +432 -0
  27. package/src/adapters/kiro/reasoning.ts +56 -0
  28. package/src/adapters/kiro/stream.ts +1153 -0
  29. package/src/adapters/kiro/usage.ts +223 -0
  30. package/src/adapters/kiro/wire.ts +76 -0
  31. package/src/adapters/kiro.ts +8 -2319
  32. package/src/adapters/mimo-free.ts +1 -1
  33. package/src/adapters/openai-chat-images.ts +101 -0
  34. package/src/adapters/openai-chat.ts +201 -181
  35. package/src/adapters/openai-responses.ts +92 -224
  36. package/src/adapters/registry.ts +0 -7
  37. package/src/adapters/run-turn-queue.ts +13 -6
  38. package/src/bridge.ts +14 -15
  39. package/src/chat/inbound.ts +29 -4
  40. package/src/chat/outbound.ts +145 -107
  41. package/src/claude/desktop-profile.ts +4 -6
  42. package/src/cli/account-api.ts +14 -0
  43. package/src/cli/account-extended.ts +1 -1
  44. package/src/cli/account-history.ts +60 -0
  45. package/src/cli/account-main.ts +80 -0
  46. package/src/cli/account.ts +11 -3
  47. package/src/cli/capabilities.ts +113 -0
  48. package/src/cli/catalog.ts +109 -0
  49. package/src/cli/dispatch.ts +9 -0
  50. package/src/cli/help.ts +2 -0
  51. package/src/cli/index.ts +2 -2
  52. package/src/cli/observe.ts +28 -1
  53. package/src/cli/opencode.ts +42 -8
  54. package/src/cli/provider-runtime.ts +11 -1
  55. package/src/cli/provider.ts +22 -2
  56. package/src/cli/registry.ts +21 -0
  57. package/src/cli/remote-workspace.ts +154 -0
  58. package/src/cli/status.ts +39 -7
  59. package/src/cli/usage-report.ts +14 -2
  60. package/src/client/hub-client.ts +34 -0
  61. package/src/client/hub-state.ts +9 -1
  62. package/src/codex/account-store.ts +78 -0
  63. package/src/codex/auth-api.ts +81 -54
  64. package/src/codex/auth-context.ts +45 -16
  65. package/src/codex/catalog/effort.ts +1 -1
  66. package/src/codex/catalog/metadata.ts +3 -6
  67. package/src/codex/catalog/native-models.ts +4 -4
  68. package/src/codex/catalog/parsing.ts +2 -20
  69. package/src/codex/catalog/provider-fetch.ts +10 -1
  70. package/src/codex/catalog/remote.ts +233 -0
  71. package/src/codex/catalog/sync.ts +403 -35
  72. package/src/codex/convergence.ts +1 -1
  73. package/src/codex/history-manifest.ts +36 -0
  74. package/src/codex/history-provider.ts +32 -5
  75. package/src/codex/inject.ts +9 -0
  76. package/src/codex/main-account.ts +113 -0
  77. package/src/codex/main-device-reauth-api.ts +89 -0
  78. package/src/codex/main-device-reauth.ts +217 -0
  79. package/src/codex/native-residue.ts +9 -2
  80. package/src/codex/quota-auto-refresh.ts +3 -2
  81. package/src/codex/quota-capacity.ts +98 -0
  82. package/src/codex/quota-history.ts +160 -0
  83. package/src/codex/quota-types.ts +8 -0
  84. package/src/codex/quota.ts +118 -91
  85. package/src/codex/refresh.ts +2 -1
  86. package/src/codex/routing.ts +90 -17
  87. package/src/codex/sync.ts +33 -4
  88. package/src/combos/request.ts +19 -1
  89. package/src/config/multi-agent-surface.ts +61 -0
  90. package/src/config/provider-validation.ts +176 -0
  91. package/src/config.ts +213 -11
  92. package/src/generated/compatibility-version.json +436 -168
  93. package/src/images/loop.ts +119 -36
  94. package/src/lib/admission.ts +12 -6
  95. package/src/lib/redact.ts +7 -0
  96. package/src/lib/translator-budget.ts +4 -3
  97. package/src/lib/windows-atomic-replace.ts +1 -0
  98. package/src/lib/windows-elevation.ts +1 -1
  99. package/src/oauth/chatgpt-device.ts +62 -5
  100. package/src/oauth/devin/cli-import.ts +130 -0
  101. package/src/oauth/devin.ts +63 -8
  102. package/src/oauth/index.ts +29 -14
  103. package/src/oauth/kiro.ts +18 -6
  104. package/src/oauth/login-cli.ts +9 -1
  105. package/src/oauth/meta-muse-device.ts +464 -0
  106. package/src/oauth/meta-muse.ts +123 -32
  107. package/src/oauth/pool-kernel.ts +9 -0
  108. package/src/oauth/pool-settings-capability.ts +2 -2
  109. package/src/oauth/store.ts +57 -0
  110. package/src/oauth/types.ts +31 -0
  111. package/src/providers/derive.ts +13 -3
  112. package/src/providers/devin-cli-authmode-migration.ts +57 -35
  113. package/src/providers/devin-provider-merge-migration.ts +240 -0
  114. package/src/providers/muse-key-quota.ts +117 -0
  115. package/src/providers/muse-subscription-usage.ts +14 -2
  116. package/src/providers/openai-sidecar.ts +25 -3
  117. package/src/providers/opencode-zen-rate-limit.ts +58 -0
  118. package/src/providers/provider-id-rewrite.ts +20 -5
  119. package/src/providers/quota-types.ts +12 -0
  120. package/src/providers/quota.ts +143 -102
  121. package/src/providers/reasoning-metadata.ts +543 -0
  122. package/src/providers/registry.ts +80 -49
  123. package/src/reasoning-effort.ts +26 -2
  124. package/src/remote/hub-usage.ts +32 -0
  125. package/src/remote-control/index.ts +192 -41
  126. package/src/remote-control/workspace-activation.ts +9 -0
  127. package/src/remote-control/workspace-agent-connection.ts +366 -0
  128. package/src/remote-control/workspace-claude-runtime.ts +243 -0
  129. package/src/remote-control/workspace-codex-runtime.ts +531 -0
  130. package/src/remote-control/workspace-codex-sandbox.ts +115 -0
  131. package/src/remote-control/workspace-command-runner.ts +748 -0
  132. package/src/remote-control/workspace-coordinator.ts +231 -0
  133. package/src/remote-control/workspace-device.ts +585 -0
  134. package/src/remote-control/workspace-executable.ts +43 -0
  135. package/src/remote-control/workspace-executor.ts +397 -0
  136. package/src/remote-control/workspace-hub.ts +519 -0
  137. package/src/remote-control/workspace-pi-runtime.ts +382 -0
  138. package/src/remote-control/workspace-process.ts +129 -0
  139. package/src/remote-control/workspace-rpc.ts +304 -0
  140. package/src/remote-control/workspace-runtime.ts +60 -0
  141. package/src/remote-control/workspace-secret-store.ts +39 -0
  142. package/src/remote-control/workspace-sessions.ts +799 -0
  143. package/src/remote-control/workspace-tool-bridge.ts +192 -0
  144. package/src/responses/code-mode-helper-compat.ts +22 -3
  145. package/src/responses/hosted-tool-policy.ts +0 -1
  146. package/src/responses/muse-tool-name-alias.ts +379 -0
  147. package/src/responses/plaintext-v2-agent-messages.ts +902 -0
  148. package/src/router.ts +7 -0
  149. package/src/routing/compatibility/behavior.ts +0 -1
  150. package/src/server/audio-client.ts +64 -0
  151. package/src/server/audio-dictation.ts +91 -0
  152. package/src/server/audio-live.ts +185 -0
  153. package/src/server/audio-transcriptions.ts +183 -0
  154. package/src/server/audio-upstream.ts +153 -0
  155. package/src/server/auth-cors.ts +61 -2
  156. package/src/server/chat-completions.ts +1 -1
  157. package/src/server/chat-native-sse.ts +92 -48
  158. package/src/server/chat-native.ts +37 -15
  159. package/src/server/hub-usage.ts +57 -0
  160. package/src/server/images.ts +4 -0
  161. package/src/server/index.ts +722 -57
  162. package/src/server/lifecycle.ts +5 -6
  163. package/src/server/live-call-bindings.ts +60 -0
  164. package/src/server/live.ts +12 -1
  165. package/src/server/management/agent-settings-routes.ts +25 -4
  166. package/src/server/management/api-access.ts +37 -0
  167. package/src/server/management/api-key-usage.ts +7 -2
  168. package/src/server/management/config-routes.ts +1 -18
  169. package/src/server/management/context.ts +15 -0
  170. package/src/server/management/logs-usage-routes.ts +2 -0
  171. package/src/server/management/oauth-account-routes.ts +39 -12
  172. package/src/server/management/provider-routes.ts +125 -2
  173. package/src/server/management/remote-workspace-routes.ts +140 -0
  174. package/src/server/management/route-registry.ts +15 -0
  175. package/src/server/management/usage-aggregate-cache.ts +14 -15
  176. package/src/server/management/usage-summary-cache.ts +2 -0
  177. package/src/server/management-api.ts +23 -0
  178. package/src/server/ports.ts +17 -0
  179. package/src/server/relay-eager.ts +4 -1
  180. package/src/server/relay.ts +70 -10
  181. package/src/server/request-decompress.ts +6 -3
  182. package/src/server/responses/agent-task-recovery.ts +25 -32
  183. package/src/server/responses/codex-auth-error.ts +11 -0
  184. package/src/server/responses/codex-ws-exchange.ts +52 -3
  185. package/src/server/responses/codex-ws-wire.ts +55 -0
  186. package/src/server/responses/compact.ts +9 -1
  187. package/src/server/responses/core.ts +337 -73
  188. package/src/server/responses/encrypted-payload.ts +45 -2
  189. package/src/server/responses/ws-upstream.ts +4 -1
  190. package/src/server/responses-self-named-namespace-scrub.ts +1 -3
  191. package/src/server/responses-undeclared-tool-guard.ts +1 -1
  192. package/src/server/search.ts +3 -0
  193. package/src/server/sse-payload-rewrite.ts +136 -51
  194. package/src/server/ws-bridge.ts +35 -1
  195. package/src/service/cli.ts +372 -0
  196. package/src/service/diagnostics.ts +340 -0
  197. package/src/service/guards.ts +303 -0
  198. package/src/service/health.ts +222 -0
  199. package/src/service/launchd.ts +853 -0
  200. package/src/service/orchestration.ts +617 -0
  201. package/src/service/repair.ts +334 -0
  202. package/src/service/state.ts +363 -0
  203. package/src/service/systemd.ts +229 -0
  204. package/src/service/windows-ops.ts +690 -0
  205. package/src/service/windows-scheduler.ts +769 -0
  206. package/src/service/windows-taskxml.ts +613 -0
  207. package/src/service.ts +22 -5550
  208. package/src/storage/cleanup/db.ts +258 -0
  209. package/src/storage/cleanup/execute.ts +358 -0
  210. package/src/storage/cleanup/paths.ts +189 -0
  211. package/src/storage/cleanup/pending.ts +140 -0
  212. package/src/storage/cleanup/preview.ts +292 -0
  213. package/src/storage/cleanup/reconcile.ts +347 -0
  214. package/src/storage/cleanup/restore.ts +932 -0
  215. package/src/storage/cleanup/satellite.ts +474 -0
  216. package/src/storage/cleanup/staging.ts +129 -0
  217. package/src/storage/cleanup/types.ts +98 -0
  218. package/src/storage/cleanup.ts +49 -3127
  219. package/src/types/accounts.ts +2 -0
  220. package/src/types/config.ts +13 -12
  221. package/src/types/provider.ts +37 -0
  222. package/src/types/request.ts +2 -0
  223. package/src/types/tools.ts +17 -5
  224. package/src/types.ts +1 -0
  225. package/src/usage/expected-prices.ts +127 -0
  226. package/src/usage/log.ts +58 -1
  227. package/src/vision/eligibility.ts +13 -2
  228. package/src/web-search/loop.ts +56 -3
  229. package/gui/dist/assets/index-CWXut3rG.js +0 -115
  230. package/gui/dist/assets/index-EdoPnm9_.css +0 -1
  231. package/src/adapters/devin-cli/acp.ts +0 -204
  232. package/src/adapters/devin-cli/adapter.ts +0 -345
  233. package/src/adapters/devin-cli/binary.ts +0 -69
  234. package/src/adapters/devin-cli/models.ts +0 -57
  235. package/src/oauth/devin-cli.ts +0 -149
  236. package/src/server/responses-reasoning-summary-rewrite.ts +0 -178
@@ -0,0 +1,154 @@
1
+ import type { RemoteWorkspaceDeviceState } from "../remote-control/workspace-device";
2
+ import {
3
+ RemoteWorkspaceDeviceFileStore,
4
+ pairRemoteWorkspaceDevice,
5
+ remoteWorkspaceCapabilitiesForCommandRunner,
6
+ runRemoteWorkspaceAgent,
7
+ type PairRemoteWorkspaceDeviceOptions,
8
+ type RemoteWorkspaceAgentRunStatus,
9
+ type RemoteWorkspaceDeviceStateStore,
10
+ } from "../remote-control/workspace-device";
11
+ import { createPlatformRemoteWorkspaceCommandRunner } from "../remote-control/workspace-command-runner";
12
+ import {
13
+ CliUsageError,
14
+ readSecretLine,
15
+ rejectArgs,
16
+ takeFlag,
17
+ takeJsonFlag,
18
+ takeOption,
19
+ type RuntimeApiDeps,
20
+ } from "./runtime-api";
21
+
22
+ export const REMOTE_WORKSPACE_USAGE = `Usage:
23
+ ocx remote-workspace pair <hub-url> --pairing-code-stdin --root <absolute-path> [--root <absolute-path> ...] [--toolchain-root <absolute-directory> ...] [--executor-helper <absolute-file>] [--name <device-name>] [--json]
24
+ ocx remote-workspace agent
25
+ ocx remote-workspace status [--json]`;
26
+
27
+ export interface RemoteWorkspaceCliDeps extends RuntimeApiDeps {
28
+ store?: RemoteWorkspaceDeviceStateStore;
29
+ pair?: (options: PairRemoteWorkspaceDeviceOptions) => Promise<RemoteWorkspaceDeviceState>;
30
+ runAgent?: typeof runRemoteWorkspaceAgent;
31
+ signal?: AbortSignal;
32
+ onStatus?: (status: RemoteWorkspaceAgentRunStatus) => void;
33
+ }
34
+
35
+ function takeRepeatedPathFlag(args: string[], flag: "--root" | "--toolchain-root"): string[] {
36
+ const roots: string[] = [];
37
+ for (;;) {
38
+ const index = args.indexOf(flag);
39
+ if (index < 0) break;
40
+ const value = args[index + 1];
41
+ if (!value || value.startsWith("--")) throw new CliUsageError(`${flag} requires an absolute path`, REMOTE_WORKSPACE_USAGE);
42
+ roots.push(value);
43
+ args.splice(index, 2);
44
+ }
45
+ return roots;
46
+ }
47
+
48
+ function publicStatus(state: RemoteWorkspaceDeviceState | null): Record<string, unknown> {
49
+ if (!state) return { paired: false };
50
+ const capabilities = remoteWorkspaceCapabilitiesForCommandRunner(
51
+ createPlatformRemoteWorkspaceCommandRunner({
52
+ linux: {
53
+ toolchainRoots: state.toolchainRoots,
54
+ writableRoots: state.roots.map(root => root.path),
55
+ },
56
+ ...(state.nativeHelper ? { native: {
57
+ helper: state.nativeHelper,
58
+ toolchainRoots: state.toolchainRoots,
59
+ writableRoots: state.roots.map(root => root.path),
60
+ } } : {}),
61
+ }),
62
+ state.capabilities,
63
+ );
64
+ return {
65
+ paired: true,
66
+ hubUrl: state.hubUrl,
67
+ deviceId: state.deviceId,
68
+ deviceName: state.deviceName,
69
+ devicePlatform: state.devicePlatform,
70
+ capabilities,
71
+ roots: state.roots.map(root => ({ id: root.id, label: root.label, path: root.path })),
72
+ toolchainRoots: state.toolchainRoots,
73
+ };
74
+ }
75
+
76
+ export async function runRemoteWorkspaceCommand(rawArgs: string[], deps: RemoteWorkspaceCliDeps = {}): Promise<number> {
77
+ const args = [...rawArgs];
78
+ const command = args.shift();
79
+ const store = deps.store ?? new RemoteWorkspaceDeviceFileStore();
80
+ if (command === "status") {
81
+ const wantsJson = takeJsonFlag(args);
82
+ rejectArgs(args, REMOTE_WORKSPACE_USAGE);
83
+ const status = publicStatus(store.load());
84
+ if (wantsJson) console.log(JSON.stringify(status, null, 2));
85
+ else if (!status.paired) console.log("Remote Workspace executor is not paired.");
86
+ else {
87
+ console.log(`Remote Workspace executor: ${status.deviceName}`);
88
+ console.log(`Hub: ${status.hubUrl}`);
89
+ console.log(`Capabilities: ${(status.capabilities as string[]).join(", ")}`);
90
+ console.log(`Workspace roots: ${(status.roots as unknown[]).length}`);
91
+ }
92
+ return 0;
93
+ }
94
+ if (command === "pair") {
95
+ const wantsJson = takeJsonFlag(args);
96
+ const readCode = takeFlag(args, "--pairing-code-stdin");
97
+ const name = takeOption(args, "--name");
98
+ const nativeHelperPath = takeOption(args, "--executor-helper");
99
+ const roots = takeRepeatedPathFlag(args, "--root");
100
+ const toolchainRoots = takeRepeatedPathFlag(args, "--toolchain-root");
101
+ const hubUrl = args.shift();
102
+ if (!hubUrl || !readCode || roots.length === 0) throw new CliUsageError(
103
+ "pair requires <hub-url>, --pairing-code-stdin, and at least one --root",
104
+ REMOTE_WORKSPACE_USAGE,
105
+ );
106
+ rejectArgs(args, REMOTE_WORKSPACE_USAGE, { redactValues: true });
107
+ const pairingCode = await readSecretLine(deps, "Remote Workspace pairing code");
108
+ const state = await (deps.pair ?? pairRemoteWorkspaceDevice)({
109
+ hubUrl,
110
+ pairingCode,
111
+ ...(name ? { name } : {}),
112
+ roots: roots.map(path => ({ path })),
113
+ toolchainRoots,
114
+ ...(nativeHelperPath ? { nativeHelperPath } : {}),
115
+ store,
116
+ });
117
+ const status = publicStatus(state);
118
+ if (wantsJson) console.log(JSON.stringify(status, null, 2));
119
+ else {
120
+ console.log(`Paired ${state.deviceName} with ${state.hubUrl}.`);
121
+ console.log("Run `ocx remote-workspace agent` to keep this executor online.");
122
+ }
123
+ return 0;
124
+ }
125
+ if (command === "agent") {
126
+ rejectArgs(args, REMOTE_WORKSPACE_USAGE);
127
+ const state = store.load();
128
+ if (!state) throw new CliUsageError("Remote Workspace executor is not paired. Run the pair command first.", REMOTE_WORKSPACE_USAGE);
129
+ const controller = deps.signal ? null : new AbortController();
130
+ const signal = deps.signal ?? controller!.signal;
131
+ const stop = () => controller?.abort();
132
+ if (controller) {
133
+ process.once("SIGINT", stop);
134
+ process.once("SIGTERM", stop);
135
+ }
136
+ try {
137
+ await (deps.runAgent ?? runRemoteWorkspaceAgent)({
138
+ state,
139
+ signal,
140
+ onStatus: deps.onStatus ?? (status => {
141
+ if (status.state === "online") console.log(`Remote Workspace executor online: ${state.deviceName}`);
142
+ if (status.state === "reconnecting" && status.message) console.error(`Remote Workspace reconnecting: ${status.message}`);
143
+ }),
144
+ });
145
+ } finally {
146
+ if (controller) {
147
+ process.removeListener("SIGINT", stop);
148
+ process.removeListener("SIGTERM", stop);
149
+ }
150
+ }
151
+ return 0;
152
+ }
153
+ throw new CliUsageError("choose pair, agent, or status", REMOTE_WORKSPACE_USAGE);
154
+ }
package/src/cli/status.ts CHANGED
@@ -18,6 +18,7 @@ import { effectiveLoopbackListenerPort } from "../codex/loopback-target";
18
18
  import { claudeDesktopIntegrationEnabled } from "../codex/desired-state";
19
19
  import { claudeDesktopPolicyHealth, probeClaudeDesktopPolicy, type ClaudeDesktopPolicyHealth } from "../claude/desktop-policy";
20
20
  import { collectClientConnectionStatus, type ClientConnectionStatus } from "./connect";
21
+ import { readClientConnectionState, sameClientConnectionOwner } from "../client/state";
21
22
  import type { HubStateOAuthEntry, HubStateProvider } from "../remote/hub-state";
22
23
  import type { HubStateSource } from "../client/hub-state";
23
24
  import { readServiceApiTokenState, serviceApiTokenFilePath } from "../lib/service-secrets";
@@ -324,6 +325,35 @@ export function disconnectedRemoteHubStatus(): CliRemoteHubStatus {
324
325
  };
325
326
  }
326
327
 
328
+ /**
329
+ * The data key this status snapshot may spend, and the cause when it may not.
330
+ *
331
+ * A reconnect or a rotation can replace both files between the snapshot and this read, so a
332
+ * matching cache owner alone does not authorize sending the current token. Withholding is only
333
+ * half the job: reporting every withheld case as "no usable data-plane token" is false for a
334
+ * client that reconnected and holds a perfectly good token for a different hub, and a cause the
335
+ * operator acts on has to be the real one (#4169 is the same defect in the stop path).
336
+ */
337
+ function boundHubStateToken(
338
+ current: ReturnType<typeof readClientConnectionState>,
339
+ token: ReturnType<typeof readServiceApiTokenState>,
340
+ owner: { serverUrl: string; apiKeyId: string; connectedAt: string },
341
+ ): { token: string | null; withheldReason?: string } {
342
+ if (current.kind !== "connected") {
343
+ return { token: null, withheldReason: "this client is no longer connected" };
344
+ }
345
+ if (!sameClientConnectionOwner(current.value, owner)) {
346
+ return { token: null, withheldReason: "the saved connection no longer matches the one this status reports" };
347
+ }
348
+ if (token.kind !== "present") {
349
+ return { token: null, withheldReason: "this client has no usable data-plane token" };
350
+ }
351
+ if (token.fingerprint !== current.value.tokenFingerprint) {
352
+ return { token: null, withheldReason: "the data-plane token no longer belongs to the saved connection" };
353
+ }
354
+ return { token: token.token };
355
+ }
356
+
327
357
  /**
328
358
  * Ask the hub what it can serve, with a bounded read and a cache fallback.
329
359
  *
@@ -340,14 +370,16 @@ export async function collectRemoteHubStatus(
340
370
  return disconnectedRemoteHubStatus();
341
371
  }
342
372
  const { resolveHubState } = await import("../client/hub-state");
343
- const token = readServiceApiTokenState();
373
+ const owner = {
374
+ serverUrl: connection.serverUrl,
375
+ apiKeyId: connection.apiKeyId,
376
+ connectedAt: connection.connectedAt,
377
+ };
378
+ const bound = boundHubStateToken(readClientConnectionState(), readServiceApiTokenState(), owner);
344
379
  const resolved = await resolveHubState({
345
- owner: {
346
- serverUrl: connection.serverUrl,
347
- apiKeyId: connection.apiKeyId,
348
- connectedAt: connection.connectedAt,
349
- },
350
- token: token.kind === "present" ? token.token : null,
380
+ owner,
381
+ token: bound.token,
382
+ ...(bound.withheldReason ? { withheldTokenReason: bound.withheldReason } : {}),
351
383
  ...(options.fetchImpl ? { fetchImpl: options.fetchImpl } : {}),
352
384
  ...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }),
353
385
  ...(options.now === undefined ? {} : { now: options.now }),
@@ -21,6 +21,10 @@ interface CostRow {
21
21
  }
22
22
 
23
23
  interface UsageReportInput {
24
+ source?: "hub";
25
+ scope?: "client";
26
+ usageIncomplete?: true;
27
+ usageIncompleteReason?: "oversized_rows";
24
28
  range?: string;
25
29
  surface?: string;
26
30
  since?: number | null;
@@ -109,11 +113,19 @@ function describeScope(data: UsageReportInput): string {
109
113
  export function formatUsageReport(data: UsageReportInput): string[] {
110
114
  const summary = data.summary ?? {};
111
115
  const lines: string[] = [describeScope(data), ""];
116
+ if (data.source === "hub" && data.scope === "client") {
117
+ lines.push("Source: hub — this client's data key only. Account totals are not shared.", "");
118
+ }
119
+ if (data.usageIncomplete === true) {
120
+ lines.push("WARNING: Usage is incomplete; some records could not be included. Totals and rankings reflect readable records only.", "");
121
+ }
112
122
 
113
123
  if (data.filter && !data.filter.matched) {
114
124
  const what = [data.filter.provider && `provider "${data.filter.provider}"`, data.filter.model && `model "${data.filter.model}"`]
115
125
  .filter(Boolean).join(" and ");
116
- lines.push(`No usage recorded for ${terminalText(what)} in this range.`);
126
+ lines.push(data.usageIncomplete === true
127
+ ? `No matching readable usage records for ${terminalText(what)} in this range; skipped records may contain matches.`
128
+ : `No usage recorded for ${terminalText(what)} in this range.`);
117
129
  lines.push("Check the spelling against `ocx usage --json`, or widen --range.");
118
130
  return lines.map(terminalText);
119
131
  }
@@ -151,7 +163,7 @@ export function formatUsageReport(data: UsageReportInput): string[] {
151
163
  // server cannot answer it honestly.
152
164
  const accountFilterActive = Boolean(data.filter?.provider || data.filter?.model);
153
165
  const accounts = (data.accounts ?? []).filter(row => row.requests > 0);
154
- if (accountFilterActive) {
166
+ if (accountFilterActive && data.scope !== "client") {
155
167
  lines.push("");
156
168
  lines.push("ACCOUNT: not reported under a provider or model filter; run without filters for per-account totals.");
157
169
  } else if (accounts.length > 0) {
@@ -1,5 +1,6 @@
1
1
  import { MAX_REMOTE_CATALOG_BYTES } from "../server/catalog-download";
2
2
  import { MAX_HUB_STATE_BYTES, parseHubStateBody, type HubStateDTO } from "../remote/hub-state";
3
+ import { MAX_HUB_USAGE_BYTES, parseHubUsage, type HubUsageReport } from "../remote/hub-usage";
3
4
  import { readBoundedResponseBytes } from "../lib/bounded-body";
4
5
  import { clearableDeadline } from "../lib/abort";
5
6
  import type { Desktop3pModelEntry } from "../claude/desktop-3p";
@@ -472,6 +473,39 @@ export async function downloadClientCatalog(
472
473
  return { kind: "fresh", body, ...(keyId ? { keyId } : {}) };
473
474
  }
474
475
 
476
+ /** Bounded own-key usage read; never falls back to a local management endpoint. */
477
+ export async function fetchHubUsage(
478
+ serverUrl: string,
479
+ admissionToken: string,
480
+ query: URLSearchParams,
481
+ options: { timeoutMs?: number; fetchImpl?: typeof fetch } = {},
482
+ ): Promise<HubUsageReport> {
483
+ const origin = normalizeHubOrigin(serverUrl);
484
+ if (!isPairingTransportPermitted(origin)) {
485
+ throw new HubClientError("insecure_http_refused", "Client usage requires HTTPS or loopback HTTP");
486
+ }
487
+ const response = await fetchBounded(options.fetchImpl ?? fetch, `${origin}/v1/usage?${query}`, {
488
+ method: "GET",
489
+ cache: "no-store",
490
+ headers: new Headers({ Accept: "application/json", "x-opencodex-api-key": admissionToken }),
491
+ }, options.timeoutMs);
492
+ if (!response.ok) {
493
+ try { await response.body?.cancel(); } catch { /* best effort */ }
494
+ const message = response.status === 404 ? "Hub does not support client usage; upgrade the hub"
495
+ : response.status === 401 || response.status === 403 ? "Hub rejected this client's usage credential"
496
+ : `Hub usage request failed (${response.status})`;
497
+ throw new HubClientError(`hub_usage_http_${response.status}`, message, response.status);
498
+ }
499
+ if (!jsonCompatibleContentType(response)) {
500
+ try { await response.body?.cancel(); } catch { /* best effort */ }
501
+ throw new HubClientError("hub_usage_invalid", "Hub usage response was not JSON");
502
+ }
503
+ const text = await boundedText(response, MAX_HUB_USAGE_BYTES, { inactivityTimeoutMs: safeTimeout(options.timeoutMs) });
504
+ const report = parseHubUsage(parseJson(text, "hub_usage_invalid"));
505
+ if (!report) throw new HubClientError("hub_usage_invalid", "Hub usage response was invalid");
506
+ return report;
507
+ }
508
+
475
509
  /**
476
510
  * Read the hub's provider/login/roster state with the per-client DATA key (#4236).
477
511
  *
@@ -161,6 +161,12 @@ export interface ResolveHubStateOptions {
161
161
  owner: HubStateOwner;
162
162
  /** The per-client data key. Null when the token file is missing or unsafe. */
163
163
  token: string | null;
164
+ /**
165
+ * Why the caller withheld the token, when it withheld one it holds. A null token reads the
166
+ * same here whether the file is missing or the caller declined to send a perfectly good
167
+ * token for a different connection, and the operator acts on that difference.
168
+ */
169
+ withheldTokenReason?: string;
164
170
  timeoutMs?: number;
165
171
  fetchImpl?: typeof fetch;
166
172
  now?: number;
@@ -195,7 +201,9 @@ export async function resolveHubState(options: ResolveHubStateOptions): Promise<
195
201
  ? withAge("cache", cached.state, cached.fetchedAt, now, reason)
196
202
  : withAge("unavailable", null, undefined, now, reason);
197
203
  };
198
- if (!options.token) return fromCache("this client has no usable data-plane token");
204
+ if (!options.token) {
205
+ return fromCache(options.withheldTokenReason ?? "this client has no usable data-plane token");
206
+ }
199
207
  if (options.allowNetwork === false) return fromCache("a live hub read was not attempted");
200
208
  let state: HubStateDTO;
201
209
  try {
@@ -13,6 +13,8 @@ import {
13
13
  import { assertNotRealHomeUnderTest } from "../lib/test-home-guard";
14
14
  import type { CodexAccountCredentialRecord, CodexAccountCredentials } from "../types";
15
15
  import { advanceCodexCredentialMutationEpoch } from "./credential-mutation-epoch";
16
+ import { isValidCodexAccountId } from "./account-id";
17
+ import type { PoolQuotaWriter } from "./quota-types";
16
18
  import { CODEX_REFRESH_FLIGHT_CEILING_MS } from "./quota-recovery-timing";
17
19
 
18
20
  type LegacyCodexAccountStore = Record<string, CodexAccountCredentials>;
@@ -163,6 +165,7 @@ export function saveCodexAccountCredential(
163
165
  generation: (current?.generation ?? 0) + 1,
164
166
  refreshGrantFingerprint,
165
167
  replacedAt: current ? Date.now() : undefined,
168
+ quotaHistoryIdentity: crypto.randomUUID(),
166
169
  ...preservedValidationMetadata(current),
167
170
  ...(options.validationPending ? {
168
171
  codexValidationPending: true,
@@ -257,6 +260,75 @@ export function readCodexAccountRecord(id: string): CodexAccountCredentialRecord
257
260
  return loadCodexAccountRecordStore()[id] ?? null;
258
261
  }
259
262
 
263
+ const QUOTA_HISTORY_IDENTITY_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
264
+
265
+ function validQuotaHistoryIdentity(value: unknown): value is string {
266
+ return typeof value === "string" && QUOTA_HISTORY_IDENTITY_RE.test(value);
267
+ }
268
+
269
+ type DispatchedPoolCredential = Pick<CodexAccountCredentials, "accessToken" | "chatgptAccountId"> & { generation: number };
270
+
271
+ function matchesDispatchedPoolCredential(record: CodexAccountCredentialRecord | undefined | null, dispatched: DispatchedPoolCredential): record is CodexAccountCredentialRecord & { credential: CodexAccountCredentials } {
272
+ return !!record?.credential && record.deletedAt == null
273
+ && dispatched.accessToken.length > 0 && dispatched.chatgptAccountId.length > 0
274
+ && Number.isSafeInteger(dispatched.generation) && dispatched.generation >= 0
275
+ && record.generation === dispatched.generation
276
+ && record.credential.accessToken === dispatched.accessToken
277
+ && record.credential.chatgptAccountId === dispatched.chatgptAccountId;
278
+ }
279
+
280
+ /** Optional evidence capture; a stale credential or unavailable store never gains a new writer. */
281
+ export function capturePoolQuotaWriter(accountId: string, dispatched: DispatchedPoolCredential): PoolQuotaWriter | undefined {
282
+ if (!isValidCodexAccountId(accountId)) return undefined;
283
+ try {
284
+ const current = readCodexAccountRecord(accountId);
285
+ if (!matchesDispatchedPoolCredential(current, dispatched)) return undefined;
286
+ if (validQuotaHistoryIdentity(current.quotaHistoryIdentity)) {
287
+ return { accountId, credentialGeneration: dispatched.generation, historyIdentity: current.quotaHistoryIdentity };
288
+ }
289
+ return withCredentialMutationLockSync(() => {
290
+ const store = loadCodexAccountRecordStore();
291
+ const locked = store[accountId];
292
+ if (!matchesDispatchedPoolCredential(locked, dispatched)) return undefined;
293
+ if (!validQuotaHistoryIdentity(locked.quotaHistoryIdentity)) {
294
+ locked.quotaHistoryIdentity = crypto.randomUUID();
295
+ // Identity metadata is not a new credential; preserve generation and mutation epoch.
296
+ persist(store);
297
+ }
298
+ return { accountId, credentialGeneration: dispatched.generation, historyIdentity: locked.quotaHistoryIdentity };
299
+ });
300
+ } catch {
301
+ // History is optional evidence. Permission, lock and disk errors cannot fail inference.
302
+ return undefined;
303
+ }
304
+ }
305
+
306
+ /** Read-only retention identity; unlike capture this never initializes legacy metadata. */
307
+ export function poolQuotaHistoryIdentity(accountId: string): string | undefined {
308
+ if (!isValidCodexAccountId(accountId)) return undefined;
309
+ try {
310
+ const record = readCodexAccountRecord(accountId);
311
+ return record?.credential && record.deletedAt == null && validQuotaHistoryIdentity(record.quotaHistoryIdentity)
312
+ ? record.quotaHistoryIdentity : undefined;
313
+ } catch {
314
+ return undefined;
315
+ }
316
+ }
317
+
318
+ /** Recheck append admission after upstream I/O; refresh may retire a writer without erasing history. */
319
+ export function isPoolQuotaWriterLive(writer: PoolQuotaWriter): boolean {
320
+ if (!isValidCodexAccountId(writer.accountId)) return false;
321
+ try {
322
+ const record = readCodexAccountRecord(writer.accountId);
323
+ return !!record?.credential && record.deletedAt == null
324
+ && record.generation === writer.credentialGeneration
325
+ && validQuotaHistoryIdentity(writer.historyIdentity)
326
+ && record.quotaHistoryIdentity === writer.historyIdentity;
327
+ } catch {
328
+ return false;
329
+ }
330
+ }
331
+
260
332
  export function isCodexAccountGenerationLive(id: string, generation: number): boolean {
261
333
  const record = readCodexAccountRecord(id);
262
334
  return !!record?.credential && record.deletedAt == null && record.generation === generation;
@@ -281,6 +353,8 @@ export function saveCodexAccountCredentialIfGeneration(
281
353
  generation: generation + 1,
282
354
  refreshGrantFingerprint,
283
355
  replacedAt: current.replacedAt,
356
+ quotaHistoryIdentity: current.credential.chatgptAccountId === cred.chatgptAccountId
357
+ ? current.quotaHistoryIdentity : crypto.randomUUID(),
284
358
  ...preservedValidationMetadata(current),
285
359
  };
286
360
  persistCredentialMutation(store);
@@ -340,6 +414,8 @@ export function commitRefreshedCodexCredentialWithAliases(
340
414
  generation: generation + 1,
341
415
  refreshGrantFingerprint,
342
416
  replacedAt: current.replacedAt,
417
+ quotaHistoryIdentity: current.credential.chatgptAccountId === cred.chatgptAccountId
418
+ ? current.quotaHistoryIdentity : crypto.randomUUID(),
343
419
  ...preservedValidationMetadata(current),
344
420
  };
345
421
 
@@ -354,6 +430,7 @@ export function commitRefreshedCodexCredentialWithAliases(
354
430
  priorFingerprint !== undefined
355
431
  && priorCredential.refreshToken !== cred.refreshToken
356
432
  && !!priorCredential.chatgptAccountId
433
+ && priorCredential.chatgptAccountId === cred.chatgptAccountId
357
434
  ) {
358
435
  for (const [aliasId, alias] of Object.entries(store)) {
359
436
  if (aliasId === id || alias.deletedAt != null || !alias.credential) continue;
@@ -369,6 +446,7 @@ export function commitRefreshedCodexCredentialWithAliases(
369
446
  generation: aliasGeneration,
370
447
  refreshGrantFingerprint,
371
448
  replacedAt: alias.replacedAt,
449
+ quotaHistoryIdentity: alias.quotaHistoryIdentity,
372
450
  ...preservedValidationMetadata(alias),
373
451
  };
374
452
  propagatedAliases.push({ id: aliasId, generation: aliasGeneration });