@bitkyc08/opencodex 2.59.0 → 2.60.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 (194) hide show
  1. package/gui/dist/assets/index-BTuCbqQd.css +1 -0
  2. package/gui/dist/assets/index-DoBVdPHP.js +134 -0
  3. package/gui/dist/index.html +2 -2
  4. package/package.json +4 -1
  5. package/src/adapters/anthropic-image-codec.ts +16 -2
  6. package/src/adapters/anthropic-image-normalize.ts +49 -2
  7. package/src/adapters/anthropic.ts +4 -1
  8. package/src/adapters/base.ts +23 -0
  9. package/src/adapters/coding-agent/turn.ts +22 -2
  10. package/src/adapters/command-code.ts +50 -3
  11. package/src/adapters/cursor/checkpoint-store.ts +3 -0
  12. package/src/adapters/cursor/discovery.ts +11 -8
  13. package/src/adapters/cursor/live-transport.ts +26 -9
  14. package/src/adapters/cursor/request-builder.ts +7 -1
  15. package/src/adapters/cursor/transport.ts +19 -0
  16. package/src/adapters/cursor.ts +3 -1
  17. package/src/adapters/devin/cloud-direct/chat.ts +3 -1
  18. package/src/adapters/devin/cloud-direct/stated-reset-retry.ts +42 -5
  19. package/src/adapters/devin.ts +50 -13
  20. package/src/adapters/google-antigravity-replay.ts +1 -1
  21. package/src/adapters/google-antigravity-wire.ts +29 -5
  22. package/src/adapters/google-http.ts +49 -10
  23. package/src/adapters/google-tool-schema.ts +595 -31
  24. package/src/adapters/google-wire-compiler.ts +93 -10
  25. package/src/adapters/google-wire-shape.ts +461 -0
  26. package/src/adapters/google.ts +37 -6
  27. package/src/adapters/openai-chat-images.ts +3 -1
  28. package/src/adapters/openai-chat.ts +5 -1
  29. package/src/adapters/openai-responses/image-gen.ts +8 -6
  30. package/src/adapters/openai-responses/passthrough.ts +15 -3
  31. package/src/adapters/openai-responses/reasoning.ts +7 -0
  32. package/src/adapters/opencode-go-additional-tools.ts +12 -2
  33. package/src/bridge/sse.ts +3 -151
  34. package/src/cli/account-extended.ts +4 -4
  35. package/src/cli/dispatch.ts +3 -3
  36. package/src/cli/doctor.ts +28 -9
  37. package/src/cli/hub.ts +3 -2
  38. package/src/cli/index.ts +7 -2
  39. package/src/cli/opencode.ts +2 -2
  40. package/src/cli/provider.ts +13 -1
  41. package/src/client/machine-api.ts +2 -2
  42. package/src/client/machine-listener.ts +2 -2
  43. package/src/client/runtime.ts +26 -2
  44. package/src/codex/account-store.ts +65 -0
  45. package/src/codex/auth-api/account-list.ts +19 -11
  46. package/src/codex/auth-api/pool-quota-probe.ts +30 -7
  47. package/src/codex/catalog/gather-capture.ts +21 -2
  48. package/src/codex/catalog/model-hints.ts +29 -28
  49. package/src/codex/catalog/parsing.ts +7 -0
  50. package/src/codex/catalog/provider-models.ts +19 -2
  51. package/src/codex/catalog/retained-sync.ts +22 -26
  52. package/src/codex/catalog/routed-gather.ts +19 -0
  53. package/src/codex/context-compat.ts +5 -2
  54. package/src/codex/desired-state.ts +4 -1
  55. package/src/codex/history-job.ts +6 -6
  56. package/src/codex/history-provider.ts +20 -166
  57. package/src/codex/history-rollout-read.ts +174 -0
  58. package/src/codex/internal/catalog-writer.ts +33 -1
  59. package/src/codex/model-cache.ts +47 -0
  60. package/src/codex/model-entitlements.ts +29 -10
  61. package/src/codex/observed-model-denials.ts +101 -8
  62. package/src/codex/prompt-text-probe.ts +9 -6
  63. package/src/codex/routing.ts +7 -1
  64. package/src/codex/shim.ts +1 -1
  65. package/src/codex/subagent-model-fallback.ts +22 -4
  66. package/src/combos/failover.ts +3 -0
  67. package/src/config/admitted-identity.ts +222 -0
  68. package/src/config/diagnostics.ts +22 -1
  69. package/src/config/feature-flags.ts +5 -0
  70. package/src/config/load-degrade.ts +18 -0
  71. package/src/config/proxy-env.ts +8 -2
  72. package/src/config/schema/compaction-triggers.ts +11 -0
  73. package/src/config/schema/config-schema.ts +4 -0
  74. package/src/config/schema/leaf-validators.ts +12 -0
  75. package/src/config.ts +2 -2
  76. package/src/generated/compatibility-version.json +249 -169
  77. package/src/grok/reset-coupons.ts +38 -19
  78. package/src/images/loop.ts +6 -1
  79. package/src/integrations/aside-profile-context.ts +37 -3
  80. package/src/integrations/aside-profile-journal.ts +68 -3
  81. package/src/integrations/aside-profiles.ts +128 -3
  82. package/src/integrations/mutation-plan.ts +815 -0
  83. package/src/integrations/writer.ts +85 -99
  84. package/src/lab/live/transport.ts +4 -0
  85. package/src/lab/live/types.ts +5 -0
  86. package/src/lab/subject/behavior-fingerprint.ts +1 -1
  87. package/src/lib/admin-secrets.ts +9 -1
  88. package/src/lib/debug-log-buffer.ts +6 -1
  89. package/src/lib/debug.ts +23 -0
  90. package/src/lib/errors.ts +79 -0
  91. package/src/lib/http-response-semantics.ts +57 -0
  92. package/src/lib/lab-live-pinned-sender.ts +26 -12
  93. package/src/lib/pinned-http.ts +142 -2
  94. package/src/lib/plain-data.ts +103 -0
  95. package/src/lib/process-control.ts +13 -5
  96. package/src/lib/provider-outbound.ts +50 -2
  97. package/src/lib/socks5-fetch.ts +136 -26
  98. package/src/lib/spend-ledger-owner.ts +364 -0
  99. package/src/lib/spend-reservation-ledger.ts +218 -27
  100. package/src/lib/windows-system-proxy.ts +16 -11
  101. package/src/oauth/callback-server.ts +4 -3
  102. package/src/oauth/generic-account-failover.ts +1 -0
  103. package/src/oauth/health.ts +12 -1
  104. package/src/oauth/index.ts +3 -107
  105. package/src/oauth/login-flow-state.ts +127 -0
  106. package/src/providers/derive.ts +34 -17
  107. package/src/providers/devin-cli-authmode-migration.ts +14 -10
  108. package/src/providers/key-failover.ts +66 -18
  109. package/src/providers/model-rename-migration.ts +55 -1
  110. package/src/providers/model-rename-startup.ts +7 -5
  111. package/src/providers/openai-virtual-models.ts +42 -2
  112. package/src/providers/quota/antigravity.ts +22 -2
  113. package/src/providers/quota/vendor-probes-key.ts +1 -1
  114. package/src/providers/registry/entries-core.ts +39 -17
  115. package/src/providers/registry/entries-extended.ts +35 -1
  116. package/src/providers/registry/model-ids.ts +168 -0
  117. package/src/providers/registry/model-seeds.ts +9 -0
  118. package/src/providers/registry/types.ts +2 -0
  119. package/src/providers/resolved-model-policy-merge.ts +167 -0
  120. package/src/providers/resolved-model-policy.ts +406 -0
  121. package/src/providers/stale-vision-classification-migration.ts +137 -0
  122. package/src/responses/apply-patch-envelope.ts +0 -12
  123. package/src/responses/freeform-wrapper-scan.ts +279 -0
  124. package/src/responses/legacy-dotted-tool-name-repair.ts +134 -0
  125. package/src/responses/progressive-freeform-input.ts +130 -0
  126. package/src/responses/reasoning-envelope.ts +30 -0
  127. package/src/responses/state.ts +5 -12
  128. package/src/responses/tool-name-aliases.ts +15 -1
  129. package/src/router.ts +91 -115
  130. package/src/routing/compatibility/behavior.ts +9 -0
  131. package/src/routing/compatibility/subject.ts +16 -1
  132. package/src/server/adapter-resolve.ts +9 -0
  133. package/src/server/auth-cors.ts +3 -0
  134. package/src/server/chat-completions.ts +5 -2
  135. package/src/server/claude-messages.ts +6 -3
  136. package/src/server/effort-row.ts +11 -3
  137. package/src/server/grok-responses-control-frame.ts +160 -1
  138. package/src/server/index/serve-options.ts +86 -29
  139. package/src/server/index/spend-ledger-lifecycle.ts +66 -0
  140. package/src/server/index/websocket-handler.ts +6 -1
  141. package/src/server/index.ts +16 -13
  142. package/src/server/management/aside-profile-routes.ts +266 -7
  143. package/src/server/management/config-routes.ts +18 -2
  144. package/src/server/management/context.ts +3 -0
  145. package/src/server/management/integration-routes.ts +287 -5
  146. package/src/server/management/metrics-routes.ts +20 -0
  147. package/src/server/management/model-rows.ts +224 -12
  148. package/src/server/management/route-registry.ts +14 -0
  149. package/src/server/management/shared.ts +10 -3
  150. package/src/server/management/system-restart.ts +7 -2
  151. package/src/server/management/system-routes.ts +2 -0
  152. package/src/server/management/usage-aggregate-cache.ts +4 -0
  153. package/src/server/management-api.ts +2 -0
  154. package/src/server/management-auth.ts +15 -1
  155. package/src/server/readiness.ts +29 -10
  156. package/src/server/relay-eager.ts +24 -2
  157. package/src/server/relay.ts +119 -10
  158. package/src/server/request-log.ts +55 -2
  159. package/src/server/request-metrics.ts +236 -0
  160. package/src/server/responses/adapter-continuation.ts +3 -3
  161. package/src/server/responses/adapter-dispatch.ts +11 -6
  162. package/src/server/responses/compact.ts +32 -10
  163. package/src/server/responses/compaction-routing.ts +111 -0
  164. package/src/server/responses/core-codex-account.ts +8 -3
  165. package/src/server/responses/core-combo.ts +7 -7
  166. package/src/server/responses/core-normalize.ts +6 -12
  167. package/src/server/responses/core-opaque-recovery.ts +1 -0
  168. package/src/server/responses/core-options.ts +4 -0
  169. package/src/server/responses/encrypted-payload.ts +20 -2
  170. package/src/server/responses/passthrough-delivery.ts +31 -14
  171. package/src/server/responses/passthrough-dispatch.ts +36 -9
  172. package/src/server/responses/policy-fallback.ts +5 -13
  173. package/src/server/responses/request-prepare.ts +67 -17
  174. package/src/server/responses/request-send-budget.ts +5 -1
  175. package/src/server/responses/request-sidecar-auth.ts +1 -1
  176. package/src/server/responses/request-transport.ts +2 -2
  177. package/src/server/responses/run-turn-execution.ts +25 -3
  178. package/src/server/responses/sidecar-execution.ts +17 -2
  179. package/src/server/responses/ws-upstream.ts +14 -27
  180. package/src/server/responses-custom-tool-repair.ts +27 -54
  181. package/src/server/responses-undeclared-tool-guard.ts +31 -1
  182. package/src/server/sse-payload-rewrite.ts +1 -1
  183. package/src/tray/windows-tray.ps1 +155 -3
  184. package/src/types/config.ts +11 -3
  185. package/src/types/provider.ts +18 -0
  186. package/src/types/request.ts +2 -0
  187. package/src/types/tools.ts +14 -0
  188. package/src/types.ts +1 -0
  189. package/src/vision/eligibility.ts +88 -9
  190. package/src/vision/plan.ts +34 -10
  191. package/src/web-search/executor.ts +41 -2
  192. package/src/web-search/loop.ts +6 -1
  193. package/gui/dist/assets/index-C5IebErG.js +0 -136
  194. package/gui/dist/assets/index-OESInAjC.css +0 -1
@@ -3,14 +3,19 @@ import { ClientPathError } from "../../clients/config-export";
3
3
  import { IntegrationMutationBusyError } from "../../integrations/mutation-flight";
4
4
  import { IntegrationWriterLockBusyError } from "../../integrations/writer-lock";
5
5
  import {
6
- getAsideProfileState, listAsideProfileStates, mutateAsideProfiles, refreshAsideProfiles,
7
- type AsideProfilesInput,
6
+ getAsideProfileState, listAsideProfileStates, mutateAsideProfiles, previewAsideProfile, refreshAsideProfiles,
7
+ type AsideProfilesInput, type AsideProfileWriteOutcome,
8
8
  } from "../../integrations/aside-profiles";
9
9
  import {
10
10
  listAsideOperations, findAsideOperation, restoreAsideProfile, deleteAsideOperation,
11
11
  asideOperationMatchesCurrent,
12
12
  } from "../../integrations/aside-profile-journal";
13
+ import type { AsideOperation } from "../../integrations/aside-profile-journal";
13
14
  import type { WriteRefused } from "../../integrations/writer";
15
+ import type { IntegrationWriteInput } from "../../integrations/writer";
16
+ import type { IntegrationMutationPlan, IntegrationPlanOperation } from "../../integrations/mutation-plan";
17
+ import { previewIntegration } from "../../integrations/mutation-plan";
18
+ import { exportSnapshotIdentity, previewExportSnapshot } from "./model-rows";
14
19
  import type { ManagementContext } from "./context";
15
20
  import { readManagementJsonBody, readOptionalManagementJsonBody, rethrowManagementBodyTooLarge } from "./body";
16
21
  import { jsonResponse } from "../auth-cors";
@@ -22,6 +27,142 @@ export interface AsideProfileRouteOptions {
22
27
 
23
28
  class ProfileQueryError extends Error { readonly status = 400; readonly code = "invalid_aside_profile"; }
24
29
 
30
+ /**
31
+ * The confirmed plan carried with this request, or null when there is none.
32
+ *
33
+ * Both fields or neither, and the operation must be the one being requested. Dropping a supplied
34
+ * binding would be the worst available behaviour: the caller believes their confirmation is being
35
+ * checked while the mutation proceeds unchecked.
36
+ */
37
+ function asideBinding(
38
+ body: { operation?: unknown; planFingerprint?: unknown },
39
+ expected: IntegrationPlanOperation,
40
+ ): { operation: IntegrationPlanOperation; fingerprint: string } | null {
41
+ const { operation, planFingerprint } = body;
42
+ if (operation === undefined && planFingerprint === undefined) return null;
43
+ if (operation === undefined || typeof planFingerprint !== "string" || planFingerprint.length === 0) {
44
+ throw new ProfileQueryError("operation and planFingerprint must be sent together");
45
+ }
46
+ if (operation !== expected) throw new ProfileQueryError("operation does not match the requested change");
47
+ return { operation: expected, fingerprint: planFingerprint };
48
+ }
49
+
50
+ /**
51
+ * The guard a bound profile change runs before it writes anything.
52
+ *
53
+ * It re-plans the same profile scope and compares. The hooks that call it sit ahead of the
54
+ * preference write and the journal import, which is the only placement that helps here: those two
55
+ * happen before any writer lock is taken, so a check under that lock would fire after the thing it
56
+ * was meant to prevent.
57
+ */
58
+ /**
59
+ * The check a bound Aside change runs, against the input that change is about to write from.
60
+ *
61
+ * It plans the prepared input it is given rather than building one of its own. Rebuilding meant
62
+ * reading the live configuration a second time, so a configuration edited to something else and
63
+ * back again while this action was in flight produced a check that agreed with a plan the write
64
+ * never described.
65
+ *
66
+ * Exported so the check itself can be exercised with a real prepared input and a roster that
67
+ * actually moved. The window it closes needs an ordinary load to complete while a mutation is
68
+ * preparing, which no test seam reaches from outside; a copy of the guard would prove nothing
69
+ * about the one the route installs. It takes only the configuration from the request context.
70
+ */
71
+ export function asideGuardFor(
72
+ ctx: Pick<ManagementContext, "config">,
73
+ capturedIdentity: string,
74
+ profileIdValue: number,
75
+ binding: { operation: IntegrationPlanOperation; fingerprint: string },
76
+ request: { opId?: string; confirmDrift?: boolean; resolved?: AsideOperation },
77
+ capture: { plan: IntegrationMutationPlan | null },
78
+ ): (prepared: IntegrationWriteInput) => Promise<AsideProfileWriteOutcome | null> {
79
+ return async prepared => {
80
+ const plan = planFor(prepared, profileIdValue, binding, request);
81
+ /*
82
+ * The roster this confirmation was planned against has to still be the retained one. Only its
83
+ * rows were carried into the mutation, so an ordinary load completing while this action
84
+ * prepared could have replaced or retired the snapshot, and fingerprinting the carried rows
85
+ * would then accept a confirmation for a roster the operator no longer has. The non-Aside
86
+ * guard verifies the same identity; this one verifies it before the preference write, which is
87
+ * the last moment that still precedes every effect this action has.
88
+ */
89
+ const rosterMoved = exportSnapshotIdentity(ctx.config) !== capturedIdentity;
90
+ if (!rosterMoved && plan.canApply && plan.fingerprint === binding.fingerprint) return null;
91
+ capture.plan = plan;
92
+ return {
93
+ ok: false,
94
+ reason: "conflict",
95
+ state: plan.state,
96
+ clientId: "aside",
97
+ message: rosterMoved
98
+ ? "the model roster changed while confirming"
99
+ : "that confirmation no longer describes this profile",
100
+ profileId: profileIdValue,
101
+ };
102
+ };
103
+ }
104
+
105
+ /**
106
+ * The same comparison, run again immediately before the document is written.
107
+ *
108
+ * Aside takes no writer lock, and its preference write and journal import sit between the first
109
+ * check and the write that check authorizes. A target edited in that window is read fresh by the
110
+ * writer, which then compares it against itself and finds nothing to object to, so a confirmation
111
+ * about the earlier file would still overwrite the later one.
112
+ *
113
+ * The roster is deliberately not re-examined here. This action has just written the operator's
114
+ * preference into the configuration, which retires the retained roster by design, so asking that
115
+ * question at this point would refuse every confirmation on principle. What is asked is the one
116
+ * thing this moment can answer: does the plan the operator confirmed still describe this file.
117
+ */
118
+ export function asideLateGuardFor(
119
+ profileIdValue: number,
120
+ binding: { operation: IntegrationPlanOperation; fingerprint: string },
121
+ request: { opId?: string; confirmDrift?: boolean; resolved?: AsideOperation },
122
+ capture: { plan: IntegrationMutationPlan | null },
123
+ ): (prepared: IntegrationWriteInput) => Promise<AsideProfileWriteOutcome | null> {
124
+ return async prepared => {
125
+ const plan = planFor(prepared, profileIdValue, binding, request);
126
+ if (plan.canApply && plan.fingerprint === binding.fingerprint) return null;
127
+ capture.plan = plan;
128
+ return {
129
+ ok: false,
130
+ reason: "conflict",
131
+ state: plan.state,
132
+ clientId: "aside",
133
+ message: "that confirmation no longer describes this profile",
134
+ profileId: profileIdValue,
135
+ };
136
+ };
137
+ }
138
+
139
+ function planFor(
140
+ prepared: IntegrationWriteInput,
141
+ profileIdValue: number,
142
+ binding: { operation: IntegrationPlanOperation; fingerprint: string },
143
+ request: { opId?: string; confirmDrift?: boolean; resolved?: AsideOperation },
144
+ ): IntegrationMutationPlan {
145
+ return previewIntegration(prepared, {
146
+ profileId: profileIdValue,
147
+ operation: binding.operation,
148
+ ...(request.opId === undefined ? {} : { opId: request.opId }),
149
+ ...(request.confirmDrift === undefined ? {} : { confirmDrift: request.confirmDrift }),
150
+ // The row the route selected, so the guard and the mutation mean the same operation even
151
+ // when more than one valid copy exists.
152
+ ...(request.resolved === undefined
153
+ ? {}
154
+ : { resolved: { entry: request.resolved.entry, store: request.resolved.store } }),
155
+ });
156
+ }
157
+
158
+ function stalePlanResponse(ctx: ManagementContext, plan: IntegrationMutationPlan): Response {
159
+ return jsonResponse({
160
+ error: "integration preview is stale",
161
+ code: "integration_preview_stale",
162
+ plan,
163
+ }, 409, ctx.req, ctx.config);
164
+ }
165
+
25
166
  const ASIDE_INTEGRATION_PATH = "/api/client-integrations/aside";
26
167
  const ASIDE_PROFILES_PATH = "/api/client-integrations/aside/profiles";
27
168
 
@@ -75,7 +216,7 @@ function nestedProfileContext(ctx: ManagementContext): { ctx: ManagementContext;
75
216
  url.searchParams.set("client", "aside");
76
217
  return { ctx: { ...ctx, url }, action: "journal" };
77
218
  }
78
- if (parts.length > 2 || !parts[0] || (parts[1] !== undefined && !["journal", "restore"].includes(parts[1]))) {
219
+ if (parts.length > 2 || !parts[0] || (parts[1] !== undefined && !["journal", "restore", "preview"].includes(parts[1]))) {
79
220
  throw new ProfileQueryError("Invalid Aside profile path");
80
221
  }
81
222
  const prior = url.searchParams.get("profile");
@@ -107,12 +248,62 @@ export async function handleAsideProfileRoutes(
107
248
  }
108
249
  return null;
109
250
  }
251
+ if (normalized.action === "preview") {
252
+ if (req.method !== "POST") return null;
253
+ // A plan describes one profile, so an unscoped preview has nothing to describe.
254
+ if (id === undefined) throw new ProfileQueryError("a plan applies to one profile");
255
+ const body = await readProfileBody(req);
256
+ if (!isObject(body)) throw new ProfileQueryError("preview body must be an object");
257
+ const operation = body.operation;
258
+ if (operation !== "apply" && operation !== "overwrite" && operation !== "disable" && operation !== "restore") {
259
+ throw new ProfileQueryError("operation must be apply, overwrite, disable or restore");
260
+ }
261
+ if (operation === "restore" && (typeof body.opId !== "string" || !body.opId.trim())) {
262
+ throw new ProfileQueryError("opId must be a non-empty string");
263
+ }
264
+ // The preview resolves the row the same way the mutation will, once, here.
265
+ const selected = operation === "restore"
266
+ ? findAsideOperation(options.input(), String(body.opId).trim(), id)
267
+ : null;
268
+ if (operation === "restore" && selected === null) {
269
+ return jsonResponse({
270
+ error: "integration operation not found",
271
+ code: "integration_operation_not_found",
272
+ }, 404, req, ctx.config);
273
+ }
274
+ const roster = previewExportSnapshot(ctx.config);
275
+ if (roster === null) {
276
+ return jsonResponse({
277
+ error: "no model roster is cached yet, so this change cannot be planned",
278
+ code: "integration_preview_unavailable",
279
+ }, 409, req, ctx.config);
280
+ }
281
+ const plan = await previewAsideProfile({ ...options.input(), models: roster.models }, {
282
+ profileId: id,
283
+ operation,
284
+ ...(operation === "restore"
285
+ ? {
286
+ opId: String(body.opId).trim(),
287
+ confirmDrift: body.confirmDrift === true,
288
+ ...(selected === null ? {} : { resolved: { entry: selected.entry, store: selected.store } }),
289
+ }
290
+ : {}),
291
+ });
292
+ return jsonResponse(plan, 200, req, ctx.config);
293
+ }
110
294
  if (normalized.action === "restore") {
111
295
  if (req.method !== "POST") return null;
112
296
  const body = await readProfileBody(req);
113
297
  if (!isObject(body) || typeof body.opId !== "string" || !body.opId.trim()
114
298
  || (body.confirmDrift !== undefined && typeof body.confirmDrift !== "boolean")) throw new ProfileQueryError("Invalid Aside restore request");
115
- return asideRestoreResponse(ctx, { opId: body.opId.trim(), confirmDrift: body.confirmDrift === true }, options);
299
+ // Rebuilding the body here previously dropped any binding, so a bound nested restore
300
+ // reached the writer with its confirmation unexamined. Forward the fields as sent.
301
+ return asideRestoreResponse(ctx, {
302
+ opId: body.opId.trim(),
303
+ confirmDrift: body.confirmDrift === true,
304
+ ...(body.operation === undefined ? {} : { operation: body.operation }),
305
+ ...(body.planFingerprint === undefined ? {} : { planFingerprint: body.planFingerprint }),
306
+ }, options);
116
307
  }
117
308
  if (url.pathname === "/api/client-integrations/aside/sync") {
118
309
  if (req.method !== "POST") return null;
@@ -134,7 +325,38 @@ export async function handleAsideProfileRoutes(
134
325
  if (!isObject(body) || typeof body.enabled !== "boolean") throw new ProfileQueryError("enabled must be a boolean");
135
326
  if (body.overwriteConflict !== undefined && typeof body.overwriteConflict !== "boolean") throw new ProfileQueryError("overwriteConflict must be a boolean");
136
327
  if (body.overwriteConflict === true && !body.enabled) throw new ProfileQueryError("overwriteConflict applies only to enabling an integration");
137
- const batch = await mutateAsideProfiles(options.input(), { enabled: body.enabled, profileId: id, overwriteConflict: body.overwriteConflict === true });
328
+ // An exact profile is required before a binding could ever mean anything: one fingerprint
329
+ // cannot honestly describe several independently changing files.
330
+ const expected: IntegrationPlanOperation = body.enabled
331
+ ? (body.overwriteConflict === true ? "overwrite" : "apply")
332
+ : "disable";
333
+ if ((body.operation !== undefined || body.planFingerprint !== undefined) && id === undefined) {
334
+ throw new ProfileQueryError("a confirmed plan applies to one profile");
335
+ }
336
+ const binding = asideBinding(body, expected);
337
+ const capture: { plan: IntegrationMutationPlan | null } = { plan: null };
338
+ let mutationInput = options.input();
339
+ let revalidate: ((prepared: IntegrationWriteInput) => Promise<AsideProfileWriteOutcome | null>) | undefined;
340
+ let revalidateBeforeWrite: ((prepared: IntegrationWriteInput) => Promise<AsideProfileWriteOutcome | null>) | undefined;
341
+ if (binding !== null && id !== undefined) {
342
+ const roster = previewExportSnapshot(ctx.config);
343
+ if (roster === null) {
344
+ return jsonResponse({
345
+ error: "no model roster is cached yet, so this change cannot be planned",
346
+ code: "integration_preview_unavailable",
347
+ }, 409, req, ctx.config);
348
+ }
349
+ // One roster for the guard and the mutation, so they cannot disagree by construction.
350
+ mutationInput = { ...mutationInput, models: roster.models };
351
+ revalidate = asideGuardFor(ctx, roster.identity, id, binding, {}, capture);
352
+ revalidateBeforeWrite = asideLateGuardFor(id, binding, {}, capture);
353
+ }
354
+ const batch = await mutateAsideProfiles(
355
+ mutationInput,
356
+ { enabled: body.enabled, profileId: id, overwriteConflict: body.overwriteConflict === true },
357
+ revalidate ? { revalidate, ...(revalidateBeforeWrite ? { revalidateBeforeWrite } : {}) } : undefined,
358
+ );
359
+ if (capture.plan) return stalePlanResponse(ctx, capture.plan);
138
360
  if (id !== undefined) {
139
361
  const result = batch.results[0];
140
362
  if (!result) throw new Error("Aside profile mutation returned no result");
@@ -180,7 +402,9 @@ export async function asideJournalResponse(
180
402
  }
181
403
 
182
404
  export async function asideRestoreResponse(
183
- ctx: ManagementContext, body: { opId: string; confirmDrift?: boolean }, options: AsideProfileRouteOptions,
405
+ ctx: ManagementContext,
406
+ body: { opId: string; confirmDrift?: boolean; operation?: unknown; planFingerprint?: unknown },
407
+ options: AsideProfileRouteOptions,
184
408
  ): Promise<Response | null> {
185
409
  try {
186
410
  validateClientSelector(ctx);
@@ -196,7 +420,42 @@ export async function asideRestoreResponse(
196
420
  if (id === undefined) return null;
197
421
  return jsonResponse({ error: "integration operation not found", code: "integration_operation_not_found", opId: body.opId }, 404, ctx.req, ctx.config);
198
422
  }
199
- const result = await restoreAsideProfile(input, { ...body, profileId: operation.profileId });
423
+ if ((body.operation !== undefined || body.planFingerprint !== undefined) && id === undefined) {
424
+ throw new ProfileQueryError("a confirmed plan applies to one profile");
425
+ }
426
+ const restoreBinding = asideBinding(body, "restore");
427
+ const capture: { plan: IntegrationMutationPlan | null } = { plan: null };
428
+ let restoreInput = input;
429
+ let revalidate: ((prepared: IntegrationWriteInput) => Promise<AsideProfileWriteOutcome | null>) | undefined;
430
+ let revalidateBeforeWrite: ((prepared: IntegrationWriteInput) => Promise<AsideProfileWriteOutcome | null>) | undefined;
431
+ if (restoreBinding !== null) {
432
+ const roster = previewExportSnapshot(ctx.config);
433
+ if (roster === null) {
434
+ return jsonResponse({
435
+ error: "no model roster is cached yet, so this change cannot be planned",
436
+ code: "integration_preview_unavailable",
437
+ }, 409, ctx.req, ctx.config);
438
+ }
439
+ restoreInput = { ...restoreInput, models: roster.models };
440
+ revalidate = asideGuardFor(ctx, roster.identity, operation.profileId, restoreBinding, {
441
+ opId: body.opId,
442
+ confirmDrift: body.confirmDrift === true,
443
+ resolved: operation,
444
+ }, capture);
445
+ revalidateBeforeWrite = asideLateGuardFor(operation.profileId, restoreBinding, {
446
+ opId: body.opId,
447
+ confirmDrift: body.confirmDrift === true,
448
+ resolved: operation,
449
+ }, capture);
450
+ }
451
+ const result = await restoreAsideProfile(
452
+ restoreInput,
453
+ // The same row the preview and guard used. Letting the mutation resolve its own copy is how
454
+ // a confirmation ends up bound to an operation other than the one that runs.
455
+ { ...body, profileId: operation.profileId, selectedOperation: operation },
456
+ revalidate ? { revalidate, ...(revalidateBeforeWrite ? { revalidateBeforeWrite } : {}) } : undefined,
457
+ );
458
+ if (capture.plan) return stalePlanResponse(ctx, capture.plan);
200
459
  return result.ok ? jsonResponse(result, 200, ctx.req, ctx.config) : options.failure(result);
201
460
  } catch (error) {
202
461
  if (error instanceof ClientPathError && !ctx.url.searchParams.has("profile")
@@ -1,3 +1,5 @@
1
+ import { compactionRoutingSchema } from "../../config/schema/leaf-validators";
2
+ import { captureConfigTopLevelRollback } from "../../config/rebase-provenance";
1
3
  import type { IntegrationClientId } from "../../integrations/registry";
2
4
  import { randomUUID } from "node:crypto";
3
5
  import { readFileSync } from "node:fs";
@@ -342,6 +344,7 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
342
344
  reason: "not_requested",
343
345
  retryable: false,
344
346
  }),
347
+ compactionRouting: config.compactionRouting ?? null,
345
348
  startupHealth: await readStartupHealth(config),
346
349
  codexRuntime: {
347
350
  path: displayCodexRuntimePath(resolved.runtime.command),
@@ -433,6 +436,7 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
433
436
  codexMainAccountHardLock?: unknown;
434
437
  codexDesktopAuthless?: unknown;
435
438
  codexClientCompaction?: unknown;
439
+ compactionRouting?: unknown;
436
440
  };
437
441
  if (body.codexAutoStart === undefined
438
442
  && body.streamMode === undefined
@@ -444,8 +448,9 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
444
448
  && body.fastRows === undefined
445
449
  && body.codexMainAccountHardLock === undefined
446
450
  && body.codexDesktopAuthless === undefined
447
- && body.codexClientCompaction === undefined) {
448
- return jsonResponse({ error: "provide codexAutoStart, streamMode, appOwnedMemoryBudgetMb, codexAccountPickerEnabled, codexQuotaAutoRefresh, oauthOpenBrowser, ultraFastTier, fastRows, codexMainAccountHardLock, codexDesktopAuthless, or codexClientCompaction" }, 400);
451
+ && body.codexClientCompaction === undefined
452
+ && body.compactionRouting === undefined) {
453
+ return jsonResponse({ error: "provide codexAutoStart, streamMode, appOwnedMemoryBudgetMb, codexAccountPickerEnabled, codexQuotaAutoRefresh, oauthOpenBrowser, ultraFastTier, fastRows, codexMainAccountHardLock, codexDesktopAuthless, codexClientCompaction, or compactionRouting" }, 400);
449
454
  }
450
455
  if (body.codexAutoStart !== undefined && typeof body.codexAutoStart !== "boolean") {
451
456
  return jsonResponse({ error: "codexAutoStart boolean is required" }, 400);
@@ -475,6 +480,12 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
475
480
  if (body.codexClientCompaction !== undefined && typeof body.codexClientCompaction !== "boolean") {
476
481
  return jsonResponse({ error: "codexClientCompaction boolean is required" }, 400);
477
482
  }
483
+ const compactionRouting = body.compactionRouting == null
484
+ ? body.compactionRouting
485
+ : compactionRoutingSchema.safeParse(body.compactionRouting);
486
+ if (compactionRouting != null && !compactionRouting.success) {
487
+ return jsonResponse({ error: "compactionRouting requires a model, an optional valid reasoningEffort, and optional non-repeating triggers drawn from \"manual\" and \"auto\"" }, 400);
488
+ }
478
489
  let quotaAutoRefreshChange: { id: string; window: "fiveHour" | "weekly"; enabled: boolean } | undefined;
479
490
  if (body.codexQuotaAutoRefresh !== undefined) {
480
491
  if (!isPlainRecord(body.codexQuotaAutoRefresh)) {
@@ -504,6 +515,7 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
504
515
  )) {
505
516
  return jsonResponse({ error: `appOwnedMemoryBudgetMb must be an integer from ${MIN_APP_OWNED_MEMORY_BUDGET_MB} to ${MAX_APP_OWNED_MEMORY_BUDGET_MB}` }, 400);
506
517
  }
518
+ const restoreCompactionRouting = captureConfigTopLevelRollback(config, ["compactionRouting"]);
507
519
  const previousSettings = {
508
520
  codexAutoStart: config.codexAutoStart,
509
521
  hasCodexAutoStart: Object.hasOwn(config, "codexAutoStart"),
@@ -570,6 +582,8 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
570
582
  else if (body.codexDesktopAuthless === false) deleteConfigTopLevelKey(config, "codexDesktopAuthless");
571
583
  if (body.codexClientCompaction === true) config.codexClientCompaction = true;
572
584
  else if (body.codexClientCompaction === false) deleteConfigTopLevelKey(config, "codexClientCompaction");
585
+ if (compactionRouting === null) deleteConfigTopLevelKey(config, "compactionRouting");
586
+ else if (compactionRouting?.success) config.compactionRouting = compactionRouting.data;
573
587
  if (quotaAutoRefreshChange) {
574
588
  const { id, window, enabled } = quotaAutoRefreshChange;
575
589
  const setting = { ...(config.codexQuotaAutoRefresh?.[id] ?? {}) };
@@ -618,6 +632,7 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
618
632
  if (previousSettings.hasCodexClientCompaction) {
619
633
  config.codexClientCompaction = previousSettings.codexClientCompaction;
620
634
  } else deleteConfigTopLevelKey(config, "codexClientCompaction");
635
+ restoreCompactionRouting();
621
636
  throw error;
622
637
  }
623
638
  if (typeof body.appOwnedMemoryBudgetMb === "number") {
@@ -669,6 +684,7 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
669
684
  codexDesktopAuthless: authlessIsEnabled,
670
685
  codexClientCompaction: clientCompactionIsEnabled,
671
686
  codexDesktopSwitches,
687
+ compactionRouting: config.compactionRouting ?? null,
672
688
  codexMainAccountHardLock: config.codexMainAccountHardLock === true,
673
689
  mainAccountHardLock: getMainAccountHardLockStatus(config),
674
690
  startupHealth: await readStartupHealth(config),
@@ -19,6 +19,7 @@ import type {
19
19
  performCodexRestart,
20
20
  readCodexAppServerState,
21
21
  } from "../../codex/app-server-restart-service";
22
+ import type { RequestMetricsSnapshotter } from "../request-metrics";
22
23
 
23
24
  import type { RemoteWorkspaceHub } from "../../remote-control/workspace-hub";
24
25
  import type { RemoteWorkspaceSessionService } from "../../remote-control/workspace-sessions";
@@ -31,6 +32,8 @@ export type RemoteWorkspaceSessionsApi = Pick<RemoteWorkspaceSessionService,
31
32
  "availability" | "list" | "create" | "prompt" | "submitPrompt" | "stop" | "shutdown">;
32
33
 
33
34
  export interface ManagementApiDeps {
35
+ /** Read-only process-local aggregate metrics; absent keeps the scrape route unavailable. */
36
+ requestMetrics?: RequestMetricsSnapshotter;
34
37
  remoteWorkspaceHub?: RemoteWorkspaceHubApi;
35
38
  remoteWorkspaceSessions?: RemoteWorkspaceSessionsApi;
36
39
  /** The listener retains and awaits teardown only after this optional subsystem activates. */