@bitkyc08/opencodex 2.75.0 → 2.76.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 (217) hide show
  1. package/README.md +6 -1
  2. package/bin/ocx.mjs +12 -1
  3. package/gui/dist/assets/App-CddorHdA.js +51 -0
  4. package/gui/dist/assets/App-DMudtISW.css +1 -0
  5. package/gui/dist/assets/{Tray-CGAD2AGk.js → Tray-DjNwwrWS.js} +1 -1
  6. package/gui/dist/assets/index-Btze1I3T.css +1 -0
  7. package/gui/dist/assets/index-CvnY7E6i.js +86 -0
  8. package/gui/dist/assets/quota-summary-B_AWpHfP.js +1 -0
  9. package/gui/dist/index.html +2 -2
  10. package/gui/dist/provider-icons/opengateway.svg +12 -0
  11. package/package.json +1 -1
  12. package/src/adapters/anthropic/account-metadata.ts +17 -0
  13. package/src/adapters/anthropic/client-identity.ts +69 -0
  14. package/src/adapters/anthropic/passthrough.ts +23 -4
  15. package/src/adapters/anthropic.ts +18 -1
  16. package/src/adapters/command-code-project-context.ts +7 -11
  17. package/src/adapters/devin/cloud-direct/tool-schema.ts +12 -48
  18. package/src/adapters/kiro-retry.ts +23 -8
  19. package/src/adapters/openai-chat/messages.ts +1 -1
  20. package/src/adapters/registry.ts +8 -1
  21. package/src/adapters/zed.ts +377 -0
  22. package/src/bridge/sse.ts +4 -2
  23. package/src/chatgpt/app-server-shim/app-server-rewrite.ts +45 -0
  24. package/src/chatgpt/app-server-shim/bundle-trust.ts +77 -0
  25. package/src/chatgpt/app-server-shim/filter.ts +157 -0
  26. package/src/chatgpt/app-server-shim/gate-rewrite.ts +99 -0
  27. package/src/chatgpt/app-server-shim/launcher.ts +91 -0
  28. package/src/claude/desktop-3p-startup.ts +62 -0
  29. package/src/claude/desktop-3p.ts +5 -0
  30. package/src/claude/first-party-settings.ts +14 -0
  31. package/src/claude/intercept/cli-catalog.ts +150 -0
  32. package/src/claude/intercept/cli-picker.ts +82 -0
  33. package/src/claude/intercept/listener.ts +21 -6
  34. package/src/claude/intercept/picker-bootstrap.ts +23 -5
  35. package/src/claude/intercept/picker-models.ts +57 -13
  36. package/src/claude/intercept/runtime.ts +30 -2
  37. package/src/claude/intercept/settings.ts +3 -0
  38. package/src/cli/agent.ts +172 -0
  39. package/src/cli/alias.ts +2 -1
  40. package/src/cli/capabilities-command.ts +3 -2
  41. package/src/cli/capabilities.ts +87 -0
  42. package/src/cli/chatgpt-command.ts +140 -0
  43. package/src/cli/combo.ts +144 -19
  44. package/src/cli/config-command.ts +3 -1
  45. package/src/cli/dispatch.ts +10 -1
  46. package/src/cli/help.ts +1 -0
  47. package/src/cli/index.ts +5 -24
  48. package/src/cli/init.ts +16 -2
  49. package/src/cli/integrations.ts +13 -0
  50. package/src/cli/internal-command.ts +12 -5
  51. package/src/cli/observe.ts +6 -1
  52. package/src/cli/registry.ts +6 -0
  53. package/src/cli/route-policy.ts +3 -1
  54. package/src/cli/runtime-api.ts +5 -2
  55. package/src/cli/status-oauth.ts +5 -0
  56. package/src/cli/status.ts +8 -1
  57. package/src/cli/stop-approval.ts +55 -16
  58. package/src/cli/stop-report.ts +5 -0
  59. package/src/clients/lazycodex.ts +48 -0
  60. package/src/clients/omo-role-models.ts +111 -0
  61. package/src/codex/account-credit-use.ts +99 -0
  62. package/src/codex/account-lifecycle.ts +2 -0
  63. package/src/codex/agent-role-models.ts +171 -0
  64. package/src/codex/auth-api/account-list.ts +10 -3
  65. package/src/codex/auth-api/routes.ts +34 -0
  66. package/src/codex/auth-context.ts +52 -11
  67. package/src/codex/catalog/discovered-natives.ts +11 -1
  68. package/src/codex/catalog/gather-capture.ts +6 -1
  69. package/src/codex/catalog/model-hints.ts +9 -1
  70. package/src/codex/catalog/provider-models.ts +60 -0
  71. package/src/codex/catalog/routed-gather.ts +30 -5
  72. package/src/codex/convergence-types.ts +2 -1
  73. package/src/codex/main-account-external-usage.ts +62 -0
  74. package/src/codex/main-account-hard-lock.ts +24 -13
  75. package/src/codex/native-profile-processes.ts +53 -16
  76. package/src/codex/prompt-layers/toml-edit.ts +1 -1
  77. package/src/codex/quota-types.ts +3 -7
  78. package/src/codex/quota.ts +19 -4
  79. package/src/codex/role-auto-assign.ts +159 -0
  80. package/src/codex/role-sizing-limits.ts +2 -0
  81. package/src/codex/role-sizing.ts +216 -0
  82. package/src/codex/routing/selection.ts +36 -0
  83. package/src/codex/routing/thread-affinity.ts +2 -0
  84. package/src/codex/routing.ts +4 -4
  85. package/src/codex/subagent-model-fallback.ts +82 -26
  86. package/src/codex/subagent-selectable-models.ts +32 -0
  87. package/src/combos/index.ts +18 -0
  88. package/src/combos/jev-decision-contract.ts +20 -0
  89. package/src/combos/jev-dispatch.ts +18 -0
  90. package/src/combos/jev-model-backend.ts +127 -0
  91. package/src/combos/jev.ts +249 -33
  92. package/src/combos/types.ts +125 -2
  93. package/src/config/diagnostics.ts +18 -0
  94. package/src/config/macos-system-proxy.ts +72 -15
  95. package/src/config/owner-registry.ts +13 -4
  96. package/src/config/process-state.ts +17 -8
  97. package/src/config/proxy-env.ts +35 -5
  98. package/src/config/schema/config-schema.ts +30 -1
  99. package/src/config/schema/leaf-validators.ts +4 -0
  100. package/src/config/serving-runtimes.ts +95 -1
  101. package/src/generated/compatibility-version.json +336 -156
  102. package/src/lib/errors.ts +7 -7
  103. package/src/lib/jsonc.ts +13 -0
  104. package/src/lib/local-chat-completion.ts +144 -0
  105. package/src/lib/provider-outbound.ts +87 -1
  106. package/src/lib/provider-runtime-fetch.ts +23 -0
  107. package/src/lib/provider-tls-profile.ts +221 -0
  108. package/src/lib/request-execution-budget.ts +16 -6
  109. package/src/lib/spend-reservation-ledger.ts +54 -20
  110. package/src/lib/sse-decoder.ts +20 -17
  111. package/src/lib/synced-state-location.ts +96 -0
  112. package/src/lib/winsw.ts +14 -5
  113. package/src/oauth/anthropic-account-refusal.ts +53 -4
  114. package/src/oauth/anthropic-model-quota.ts +83 -0
  115. package/src/oauth/anthropic-rate-limit-policy.ts +37 -0
  116. package/src/oauth/anthropic-routing.ts +112 -58
  117. package/src/oauth/health.ts +39 -3
  118. package/src/oauth/index.ts +14 -0
  119. package/src/oauth/kiro-account-load.ts +10 -0
  120. package/src/oauth/zed.ts +134 -0
  121. package/src/providers/model-discovery.ts +19 -8
  122. package/src/providers/model-rename-fields.ts +1 -0
  123. package/src/providers/provider-id-rewrite.ts +9 -2
  124. package/src/providers/quota/account-cache.ts +29 -7
  125. package/src/providers/quota/anthropic-cooldown-recovery.ts +18 -0
  126. package/src/providers/quota/anthropic-family-headers.ts +25 -0
  127. package/src/providers/quota/vendor-probes-oauth.ts +5 -1
  128. package/src/providers/quota-types.ts +4 -0
  129. package/src/providers/registry/entries-core.ts +18 -0
  130. package/src/providers/registry/entries-extended.ts +55 -0
  131. package/src/providers/registry/types.ts +3 -0
  132. package/src/providers/xai-fast-model.ts +12 -4
  133. package/src/providers/zed.ts +598 -0
  134. package/src/responses/state/snapshot-select.ts +22 -9
  135. package/src/responses/state.ts +2 -3
  136. package/src/router.ts +9 -0
  137. package/src/server/anthropic-rate-limit-headers.ts +7 -0
  138. package/src/server/auth-cors.ts +7 -0
  139. package/src/server/chat-completions.ts +9 -4
  140. package/src/server/claude-messages.ts +59 -21
  141. package/src/server/index/claude-intercept-lifecycle.ts +71 -25
  142. package/src/server/index/optional-listeners.ts +4 -0
  143. package/src/server/index/spend-ledger-lifecycle.ts +4 -0
  144. package/src/server/management/agent-settings-routes.ts +74 -37
  145. package/src/server/management/claude-desktop-picker-routes.ts +7 -0
  146. package/src/server/management/claude-intercept-routes.ts +35 -0
  147. package/src/server/management/codex-agent-role-routes.ts +122 -0
  148. package/src/server/management/codex-role-auto-assign.ts +208 -0
  149. package/src/server/management/combo-routes.ts +62 -5
  150. package/src/server/management/config-routes.ts +35 -3
  151. package/src/server/management/context.ts +4 -1
  152. package/src/server/management/decision-discovery.ts +74 -0
  153. package/src/server/management/decision-model-validation.ts +80 -0
  154. package/src/server/management/decision-routes.ts +160 -0
  155. package/src/server/management/native-integration-routes.ts +16 -0
  156. package/src/server/management/provider-routes.ts +30 -34
  157. package/src/server/management/route-registry.ts +12 -0
  158. package/src/server/management/sibling-guard.ts +1 -0
  159. package/src/server/management/zed-provider-probe.ts +38 -0
  160. package/src/server/management-api.ts +12 -1
  161. package/src/server/messages-native-oauth.ts +7 -2
  162. package/src/server/messages-native.ts +7 -3
  163. package/src/server/relay.ts +3 -1
  164. package/src/server/request-log.ts +2 -0
  165. package/src/server/responses/adapter-continuation.ts +3 -3
  166. package/src/server/responses/adapter-dispatch.ts +18 -3
  167. package/src/server/responses/compact.ts +14 -1
  168. package/src/server/responses/core-auth.ts +2 -0
  169. package/src/server/responses/core-combo.ts +67 -11
  170. package/src/server/responses/core-normalize.ts +21 -1
  171. package/src/server/responses/core-options.ts +11 -1
  172. package/src/server/responses/core.ts +5 -3
  173. package/src/server/responses/fetch-helpers.ts +6 -3
  174. package/src/server/responses/jev-model-invoke.ts +190 -0
  175. package/src/server/responses/passthrough-delivery.ts +16 -0
  176. package/src/server/responses/policy-fallback.ts +55 -3
  177. package/src/server/responses/policy-request-scope.ts +80 -0
  178. package/src/server/responses/request-prepare.ts +70 -18
  179. package/src/server/responses/request-transport.ts +54 -23
  180. package/src/server/responses/run-turn-execution.ts +1 -0
  181. package/src/server/responses/sidecar-execution.ts +9 -3
  182. package/src/server/responses-hosted-image-display.ts +225 -0
  183. package/src/service/managing-cli.ts +34 -4
  184. package/src/service/systemd-env.ts +75 -0
  185. package/src/service/windows-guarded-manager.ts +60 -19
  186. package/src/service/windows-scheduler.ts +22 -3
  187. package/src/service/windows-taskxml.ts +32 -10
  188. package/src/service-manager-probe.ts +72 -19
  189. package/src/types/config.ts +31 -1
  190. package/src/types/provider.ts +2 -0
  191. package/src/types/request.ts +2 -0
  192. package/src/update/index.ts +39 -6
  193. package/src/update/job.ts +63 -63
  194. package/src/update/npm-cache-preflight.d.mts +33 -1
  195. package/src/update/npm-cache-preflight.mjs +131 -18
  196. package/src/update/ownership-transaction.ts +19 -1
  197. package/src/update/restart-ownership.ts +80 -6
  198. package/src/update/transactional-install.d.mts +2 -0
  199. package/src/update/transactional-install.mjs +6 -1
  200. package/src/usage/jev-stats.ts +28 -1
  201. package/src/usage/log.ts +1 -1
  202. package/src/vision/anthropic-describe.ts +5 -3
  203. package/src/vision/index.ts +1 -0
  204. package/src/vision/plan.ts +2 -1
  205. package/src/vision/routed-describe.ts +29 -121
  206. package/src/web-search/alpha-search.ts +3 -3
  207. package/src/web-search/anthropic-executor.ts +6 -4
  208. package/src/web-search/index.ts +1 -1
  209. package/src/web-search/loop.ts +3 -3
  210. package/src/web-search/passthrough-bridge.ts +2 -1
  211. package/src/web-search/run-turn-loop.ts +1 -1
  212. package/src/web-search/sidecar-providers.ts +2 -1
  213. package/gui/dist/assets/App-ia4U0ufY.css +0 -1
  214. package/gui/dist/assets/App-xm0zGNOe.js +0 -51
  215. package/gui/dist/assets/index-DBT3Q31Y.css +0 -1
  216. package/gui/dist/assets/index-D_RGAT6Q.js +0 -86
  217. package/gui/dist/assets/quota-summary-CUFq48t-.js +0 -1
@@ -0,0 +1,225 @@
1
+ import { mkdirSync, writeFileSync, unlinkSync } from "node:fs";
2
+ import { createHash, randomUUID } from "node:crypto";
3
+ import { join, posix } from "node:path";
4
+ import { getConfigDir } from "../config";
5
+ import { recordOwnedConfigPath } from "../lib/config-ownership";
6
+ import {
7
+ chargeImageBudget, createImageBudget, decodeValidatedImageBase64,
8
+ artifactHttpUrl, getArtifactsDir, MAX_ENCODED_BYTES_PER_IMAGE, pruneArtifacts, sniffImageExtension,
9
+ } from "../images/artifacts";
10
+ import { sseDataPayload, replaceSseDataPayload, type SseBlockRewrite } from "./sse-payload-rewrite";
11
+
12
+ type Row = Record<string, any>;
13
+ const object = (v: unknown): v is Row => !!v && typeof v === "object" && !Array.isArray(v);
14
+ const originators = new Set(["codex_cli_rs", "Codex Desktop", "codex_app", "codex_work_desktop"]);
15
+ const generatedName = /^img-codex-[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}\.(png|jpg|webp|gif)$/;
16
+
17
+ /** One spelling per local path: file URL or plain, either separator, dot segments, folded case. */
18
+ function comparablePath(value: string, foldCase: boolean): string | undefined {
19
+ let path = value;
20
+ if (/^file:\/\//i.test(path)) {
21
+ try { path = decodeURIComponent(path.replace(/^file:\/\/(?:localhost)?/i, "")); } catch { return undefined; }
22
+ if (/^\/[A-Za-z]:/.test(path)) path = path.slice(1);
23
+ }
24
+ path = posix.normalize(path.replace(/\\/g, "/"));
25
+ return foldCase ? path.toLowerCase() : path;
26
+ }
27
+
28
+ /**
29
+ * Full client replay can include display-only messages even without their generated ids.
30
+ * Shared by Responses request preparation and the remote compaction handler.
31
+ */
32
+ export function redactHostedImageDisplayPaths(body: unknown, platform: NodeJS.Platform = process.platform): void {
33
+ if (!object(body) || !Array.isArray(body.input)) return;
34
+ // macOS and Windows filesystems fold case by default, so either spelling names the artifact.
35
+ const foldCase = platform === "darwin" || platform === "win32";
36
+ const dir = comparablePath(getArtifactsDir(), foldCase);
37
+ const generatedLink = /!\[Generated image\]\(<([^>\r\n]+)>\)/g;
38
+ const redact = (text: string) => text.replace(generatedLink, (link, path: string) => {
39
+ const candidate = comparablePath(path, foldCase);
40
+ const slash = candidate?.lastIndexOf("/") ?? -1;
41
+ if (!candidate || !dir || slash < 0 || candidate.slice(0, slash) !== dir) return link;
42
+ const name = candidate.slice(slash + 1);
43
+ if (!generatedName.test(name)) return link;
44
+ // No file read or existence check: history may outlive artifact retention.
45
+ return "![Generated image](<" + artifactHttpUrl(name) + ">)";
46
+ });
47
+ for (const item of body.input) {
48
+ if (!object(item) || item.role !== "assistant"
49
+ || (item.type !== undefined && item.type !== "message")) continue;
50
+ if (typeof item.content === "string") item.content = redact(item.content);
51
+ else if (Array.isArray(item.content)) {
52
+ for (const part of item.content) {
53
+ if (object(part) && ["output_text", "input_text", "text"].includes(part.type)
54
+ && typeof part.text === "string") part.text = redact(part.text);
55
+ }
56
+ }
57
+ }
58
+ }
59
+
60
+ /** Local filesystem links are only appropriate for the loopback Codex client. */
61
+ export function isLocalCodexImageClient(headers: Headers, admissionKind?: string, inboundWire?: string): boolean {
62
+ return admissionKind === "loopback"
63
+ && (inboundWire === undefined || inboundWire === "responses")
64
+ && (originators.has(headers.get("originator") ?? "")
65
+ || /^codex(?:[_ /-]|$)/i.test(headers.get("user-agent") ?? ""));
66
+ }
67
+
68
+ /** Client branch only: raw hosted items remain intact in the upstream replay cache. */
69
+ export function createHostedImageDisplayRewrite(): SseBlockRewrite & { json(text: string): string } {
70
+ const budget = createImageBudget();
71
+ const states = new Map<string, { message: Row; added: boolean; done: boolean }>();
72
+ let extraSequence = 0;
73
+ let wroteArtifact = false;
74
+
75
+ function stateFor(item: Row, index: number) {
76
+ const key = typeof item.id === "string" ? item.id : "index:" + index;
77
+ let state = states.get(key);
78
+ if (state) return state;
79
+ // Bound metadata independently of the existing 100 MiB decoded-image budget.
80
+ if (states.size >= 128) throw new RangeError("hosted image result count exceeds local display limit");
81
+ const id = "msg_ocx_img_" + createHash("sha256").update(key).digest("hex").slice(0, 32);
82
+ state = { added: false, done: false, message: {
83
+ // Persist image results outside the client's collapsible progress messages.
84
+ id, type: "message", role: "assistant", phase: "final_answer", status: "in_progress", content: [],
85
+ ...(item.internal_chat_message_metadata_passthrough === undefined ? {} : {
86
+ internal_chat_message_metadata_passthrough: item.internal_chat_message_metadata_passthrough,
87
+ }),
88
+ } };
89
+ states.set(key, state);
90
+ return state;
91
+ }
92
+
93
+ function complete(item: Row, index: number) {
94
+ const state = stateFor(item, index);
95
+ if (state.message.status !== "in_progress") return state;
96
+ let text = item.status === "completed"
97
+ ? "The completed image result has no supported image data for local display."
98
+ : "Image generation did not complete; no image is available to display.";
99
+ let status = "incomplete";
100
+ if (item.status === "completed" && typeof item.result === "string") {
101
+ try {
102
+ if (item.result.length > MAX_ENCODED_BYTES_PER_IMAGE) throw new RangeError("image too large");
103
+ const bytes = decodeValidatedImageBase64(item.result);
104
+ chargeImageBudget(budget, bytes.length);
105
+ const dir = getArtifactsDir();
106
+ recordOwnedConfigPath(getConfigDir(), dir);
107
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
108
+ const candidate = join(dir, "img-codex-" + randomUUID() + "." + sniffImageExtension(bytes));
109
+ // Bounded synchronous write: block rewriters cannot await. Never use upstream ids as paths.
110
+ try {
111
+ writeFileSync(candidate, bytes, { flag: "wx", mode: 0o600 });
112
+ } catch (error) {
113
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") {
114
+ try { unlinkSync(candidate); } catch { /* best-effort partial-write cleanup */ }
115
+ }
116
+ throw error;
117
+ }
118
+ wroteArtifact = true;
119
+ text = "![Generated image](<" + candidate + ">)";
120
+ status = "completed";
121
+ } catch {
122
+ // Do not expose image data, filesystem errors, or credentials in a generated message.
123
+ text = "The image result could not be saved for local display.";
124
+ }
125
+ }
126
+ state.message = { ...state.message, status, content: [{ type: "output_text", text, annotations: [] }] };
127
+ return state;
128
+ }
129
+
130
+ function doneEvents(item: Row, index: number): Row[] {
131
+ const state = complete(item, index);
132
+ if (state.done) return [];
133
+ const { message } = state;
134
+ const common = { item_id: message.id, output_index: index, content_index: 0 };
135
+ const part = message.content[0];
136
+ const events: Row[] = [];
137
+ if (!state.added) events.push({ type: "response.output_item.added", output_index: index,
138
+ item: { ...message, status: "in_progress", content: [] } });
139
+ events.push(
140
+ { type: "response.content_part.added", ...common, part: { ...part, text: "" } },
141
+ { type: "response.output_text.delta", ...common, delta: part.text },
142
+ { type: "response.output_text.done", ...common, text: part.text },
143
+ { type: "response.content_part.done", ...common, part },
144
+ { type: "response.output_item.done", output_index: index, item: message },
145
+ );
146
+ state.added = state.done = true;
147
+ return events;
148
+ }
149
+
150
+ function snapshot(response: Row, terminal: boolean, events?: Row[]): Row {
151
+ if (!Array.isArray(response.output)) return response;
152
+ // Reject oversized snapshots before saving artifacts that cannot be delivered.
153
+ const keys = new Set(states.keys());
154
+ for (const [index, item] of response.output.entries()) {
155
+ if (!object(item) || item.type !== "image_generation_call") continue;
156
+ keys.add(typeof item.id === "string" ? item.id : "index:" + index);
157
+ if (keys.size > 128) throw new RangeError("hosted image result count exceeds local display limit");
158
+ }
159
+ let changed = false;
160
+ const output = response.output.map((item: unknown, index: number) => {
161
+ if (!object(item) || item.type !== "image_generation_call") return item;
162
+ changed = true;
163
+ if (terminal) {
164
+ if (events) events.push(...doneEvents(item, index));
165
+ return complete(item, index).message;
166
+ }
167
+ return stateFor(item, index).message;
168
+ });
169
+ return changed ? { ...response, output } : response;
170
+ }
171
+
172
+ const rewrite = ((block: string): readonly string[] => {
173
+ const payload = sseDataPayload(block);
174
+ if (!payload || payload === "[DONE]") return [block];
175
+ let event: Row;
176
+ try { event = JSON.parse(payload); } catch { return [block]; }
177
+ if (!object(event)) return [block];
178
+ let result: Row[] = [event];
179
+ const hasIndex = Number.isSafeInteger(event.output_index) && event.output_index >= 0;
180
+ const index = hasIndex ? event.output_index : 0;
181
+ if (object(event.item) && event.item.type === "image_generation_call"
182
+ && (hasIndex || typeof event.item.id === "string")) {
183
+ if (event.type === "response.output_item.added") {
184
+ const state = stateFor(event.item, index);
185
+ result = state.added ? [] : [{ ...event, item: state.message }];
186
+ state.added = true;
187
+ } else if (event.type === "response.output_item.done") {
188
+ result = doneEvents(event.item, index);
189
+ }
190
+ } else if (typeof event.type === "string" && event.type.startsWith("response.image_generation_call.")) {
191
+ // Hosted progress/partial-image frames refer to the replaced item; never expose mismatched ids.
192
+ result = [];
193
+ } else if (object(event.response)) {
194
+ const terminal = ["response.completed", "response.incomplete", "response.failed"].includes(event.type);
195
+ const injected: Row[] = [];
196
+ const response = snapshot(event.response, terminal, injected);
197
+ if (response !== event.response) result = [...injected, { ...event, response }];
198
+ }
199
+ const sequence = Number.isSafeInteger(event.sequence_number) ? event.sequence_number + extraSequence : undefined;
200
+ const shift = extraSequence;
201
+ extraSequence += result.length - 1;
202
+ return result.map((value, offset) => {
203
+ if (value === event && shift === 0 && result.length === 1) return block;
204
+ const next = sequence === undefined ? value : { ...value, sequence_number: sequence + offset };
205
+ const data = JSON.stringify(next);
206
+ return value.type === event.type ? replaceSseDataPayload(block, data) : "event: " + value.type + "\ndata: " + data;
207
+ });
208
+ }) as SseBlockRewrite & { json(text: string): string };
209
+
210
+ rewrite.json = (text: string) => {
211
+ let response: unknown;
212
+ try { response = JSON.parse(text); } catch { return text; }
213
+ if (!object(response)) return text;
214
+ const next = snapshot(response, true);
215
+ return next === response ? text : JSON.stringify(next);
216
+ };
217
+ rewrite.dispose = () => {
218
+ states.clear();
219
+ if (wroteArtifact) {
220
+ wroteArtifact = false;
221
+ pruneArtifacts();
222
+ }
223
+ };
224
+ return rewrite;
225
+ }
@@ -26,6 +26,13 @@ import {
26
26
  import type { ServiceInstallState } from "./state";
27
27
 
28
28
  const VERSION_PROBE_TIMEOUT_MS = 5000;
29
+ /**
30
+ * A `--version` spawn is the only evidence for a PATH shim; a transient timeout or a
31
+ * busy cold start turns an unchanged manager into `unknown`, which flips both the
32
+ * takeover answer and the compatibility token the next resolve compares. One bounded
33
+ * retry keeps the observation stable while remaining fail-closed on a real failure.
34
+ */
35
+ const VERSION_PROBE_ATTEMPTS = 2;
29
36
 
30
37
  export interface ManagingCliDeps {
31
38
  /** Environment to scan PATH/PATHEXT in. Defaults to `process.env`. */
@@ -52,12 +59,12 @@ function versionFromOutput(stdout: string): string | null {
52
59
  return null;
53
60
  }
54
61
 
55
- function probeVersion(
62
+ function probeVersionOnce(
56
63
  executable: string,
57
64
  args: readonly string[],
58
65
  deps: Required<Pick<ManagingCliDeps, "spawn" | "platform" | "env">>,
66
+ identity: string,
59
67
  ): ManagingCliObservation {
60
- const identity = [executable, ...args].join(" ");
61
68
  let result: SpawnSyncReturns<string>;
62
69
  try {
63
70
  const windowsShim =
@@ -65,10 +72,17 @@ function probeVersion(
65
72
  if (windowsShim && [executable, ...args].some(part => /[&|<>^%!"()]/.test(part))) {
66
73
  return { status: "unknown", reason: "the selected Windows command shim invocation cannot be probed safely" };
67
74
  }
75
+ // cmd /c re-parses the remainder of its command line: with more than one quoted
76
+ // part it strips the outer quotes and truncates at the first space, so every part
77
+ // must be quoted individually inside one wrapping pair, passed verbatim.
78
+ const shimCommand = `"${[executable, ...args, "--version"].map(part => `"${part}"`).join(" ")}"`;
68
79
  result = deps.spawn(
69
80
  windowsShim ? (deps.env?.ComSpec ?? "cmd.exe") : executable,
70
- windowsShim ? ["/c", executable, ...args, "--version"] : [...args, "--version"],
71
- { timeout: VERSION_PROBE_TIMEOUT_MS, encoding: "utf8", stdio: "pipe", windowsHide: true },
81
+ windowsShim ? ["/c", shimCommand] : [...args, "--version"],
82
+ {
83
+ timeout: VERSION_PROBE_TIMEOUT_MS, encoding: "utf8", stdio: "pipe",
84
+ windowsHide: true, ...(windowsShim ? { windowsVerbatimArguments: true } : {}),
85
+ },
72
86
  ) as SpawnSyncReturns<string>;
73
87
  } catch (error) {
74
88
  return {
@@ -91,6 +105,22 @@ function probeVersion(
91
105
  : { status: "observed", version, identity };
92
106
  }
93
107
 
108
+ function probeVersion(
109
+ executable: string,
110
+ args: readonly string[],
111
+ deps: Required<Pick<ManagingCliDeps, "spawn" | "platform" | "env">>,
112
+ ): ManagingCliObservation {
113
+ const identity = [executable, ...args].join(" ");
114
+ let observation = probeVersionOnce(executable, args, deps, identity);
115
+ // One bounded retry, Windows only (the platform the flake family was verified on):
116
+ // a single shim-timeout answer must not flip the fingerprint of an unchanged CLI.
117
+ const attempts = deps.platform === "win32" ? VERSION_PROBE_ATTEMPTS : 1;
118
+ for (let attempt = 1; observation.status !== "observed" && attempt < attempts; attempt += 1) {
119
+ observation = probeVersionOnce(executable, args, deps, identity);
120
+ }
121
+ return observation;
122
+ }
123
+
94
124
  /**
95
125
  * Where `ocx` resolves on PATH without spawning `where`/`which`.
96
126
  *
@@ -0,0 +1,75 @@
1
+ type SystemdHomes = { codexHome: string | null; opencodexHome: string | null };
2
+ type SystemdHomeParse = { kind: "parsed"; homes: SystemdHomes } | { kind: "invalid" };
3
+
4
+ /** Inverse of systemd.ts's generated quoted values, without expanding systemd specifiers. */
5
+ function decodeQuoted(value: string): string | undefined {
6
+ let decoded = "";
7
+ for (let index = 0; index < value.length; index++) {
8
+ const char = value[index]!;
9
+ if (char === "%") {
10
+ if (value[++index] !== "%") return undefined;
11
+ decoded += "%";
12
+ } else if (char === "\\") {
13
+ const escaped = value[++index];
14
+ if (escaped === "n") decoded += "\n";
15
+ else if (escaped === "\\" || escaped === '"') decoded += escaped;
16
+ else return undefined;
17
+ } else if (char === '"' || char === "\0") {
18
+ return undefined;
19
+ } else {
20
+ decoded += char;
21
+ }
22
+ }
23
+ return decoded;
24
+ }
25
+
26
+ /**
27
+ * Missing homes are null; unsupported or ambiguous assignments invalidate the entire definition.
28
+ *
29
+ * A directive name is everything before the first `=`, stripped — systemd matches that
30
+ * name literally and case-sensitively, so only the exact `Environment` can carry the
31
+ * home assignments this parser decodes. Every other name must be a plain
32
+ * non-environment identifier to be skipped: `EnvironmentFile=`, `PassEnvironment=` and
33
+ * `UnsetEnvironment=` mutate the applied environment the same way, an escaped or
34
+ * malformed name cannot be proven inert, and a line ending in an odd number of
35
+ * backslashes continues into the next under systemd's parser rather than standing on
36
+ * its own — all of them invalidate the definition instead of passing over silently.
37
+ */
38
+ export function parseSystemdUnitHomes(body: string): SystemdHomeParse {
39
+ const homes: SystemdHomes = { codexHome: null, opencodexHome: null };
40
+ for (const rawLine of body.split(/\r?\n/)) {
41
+ const line = rawLine.trim();
42
+ if (!line || /^[#;\[]/.test(line)) continue;
43
+ // systemd folds physical continuations before recognizing directive names. The writer never
44
+ // emits them; accepting individual lines could hide an override or invent a home assignment.
45
+ const trailingBackslashes = /\\+$/.exec(rawLine)?.[0].length ?? 0;
46
+ if (trailingBackslashes % 2 === 1) return { kind: "invalid" };
47
+ const separator = line.indexOf("=");
48
+ const lvalue = (separator === -1 ? line : line.slice(0, separator)).trim();
49
+ if (lvalue !== "Environment") {
50
+ if (!/^[A-Za-z][A-Za-z0-9_.\-]*$/.test(lvalue) || lvalue.includes("Environment")) {
51
+ return { kind: "invalid" };
52
+ }
53
+ continue;
54
+ }
55
+ const directive = /^Environment\s*=\s*(.*)$/.exec(line);
56
+ if (!directive) return { kind: "invalid" };
57
+ const encoded = directive[1]!;
58
+ let assignment: string | undefined;
59
+ if (encoded.startsWith('"') && encoded.endsWith('"')) {
60
+ assignment = decodeQuoted(encoded.slice(1, -1));
61
+ } else if (encoded.length > 0 && !/[\s"'\\%\0]/.test(encoded)) {
62
+ // Older writers emitted one unquoted assignment. Anything needing quoting stays unknown.
63
+ assignment = encoded;
64
+ }
65
+ if (assignment === undefined) return { kind: "invalid" };
66
+ const pair = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/s.exec(assignment);
67
+ if (!pair) return { kind: "invalid" };
68
+ const name = pair[1]!;
69
+ if (name !== "CODEX_HOME" && name !== "OPENCODEX_HOME") continue;
70
+ const key = name === "CODEX_HOME" ? "codexHome" : "opencodexHome";
71
+ if (homes[key] !== null || pair[2] === "") return { kind: "invalid" };
72
+ homes[key] = pair[2]!;
73
+ }
74
+ return { kind: "parsed", homes };
75
+ }
@@ -59,6 +59,13 @@ export interface WindowsGuardedManagerDeps {
59
59
  winProcs?: () => WindowsProcessEntry[] | null;
60
60
  winTaskXml?: () => string;
61
61
  winTaskState?: () => WindowsTaskState;
62
+ /**
63
+ * Presence re-probe, invoked only on the failure paths that cannot tell a
64
+ * DELETED registration from an unreadable one. Absence is the one answer
65
+ * that changes the verdict — a registration that is gone can respawn
66
+ * nothing; "present" and "unknown" both stay unknown.
67
+ */
68
+ winTaskProbe?: () => WindowsSchedulerTaskProbe;
62
69
  winService?: () => WindowsWinswServiceInfo | null;
63
70
  winRegistrationOurs?: (xml: string, expectedUserIds: readonly string[] | null) => boolean;
64
71
  winTaskUserIds?: () => readonly string[] | null;
@@ -121,6 +128,18 @@ export function windowsProcessList(): WindowsProcessEntry[] | null {
121
128
  }
122
129
  }
123
130
 
131
+ /**
132
+ * The PowerShell behind windowsScheduledTaskState — exported so the folder scope
133
+ * is reviewable. `-TaskName` alone is a folder-AGNOSTIC name filter: a same-named
134
+ * task in any folder answers the query (and several answers leave `.State` an
135
+ * array that reads as "unknown"), while every `/tn` operation the caller runs is
136
+ * root-folder scoped. `-TaskPath '\'` pins the read to the root registration.
137
+ */
138
+ export function windowsScheduledTaskStateQuery(taskName: string): string {
139
+ return "(Get-ScheduledTask -TaskName '" + taskName.replace(/'/g, "''")
140
+ + "' -TaskPath '\\' -ErrorAction Stop).State";
141
+ }
142
+
124
143
  /**
125
144
  * The registered task's RUNNING state, read through Get-ScheduledTask whose State
126
145
  * values are enum names and therefore locale-independent — parsing schtasks table
@@ -129,9 +148,7 @@ export function windowsProcessList(): WindowsProcessEntry[] | null {
129
148
  export function windowsScheduledTaskState(taskName = TASK): WindowsTaskState {
130
149
  if (process.platform !== "win32") return "unknown";
131
150
  try {
132
- const output = runPowerShell(
133
- "(Get-ScheduledTask -TaskName '" + taskName.replace(/'/g, "''") + "' -ErrorAction Stop).State",
134
- ).trim();
151
+ const output = runPowerShell(windowsScheduledTaskStateQuery(taskName)).trim();
135
152
  if (/^running$/i.test(output)) return "running";
136
153
  if (/^(ready|disabled)$/i.test(output)) return "not-running";
137
154
  return "unknown";
@@ -229,6 +246,9 @@ function resolveWindowsDeps(deps: WindowsGuardedManagerDeps, live: boolean): Res
229
246
  throw new Error("Windows task query is unavailable in this context");
230
247
  }),
231
248
  winTaskState: deps.winTaskState ?? (live ? () => windowsScheduledTaskState() : () => "unknown"),
249
+ winTaskProbe: deps.winTaskProbe ?? (live
250
+ ? () => probeWindowsSchedulerTask()
251
+ : () => ({ status: "unknown", detail: "the Task Scheduler probe is unavailable in this context" })),
232
252
  winService: deps.winService ?? (live ? () => windowsWinswServiceInfo() : () => null),
233
253
  winRegistrationOurs: deps.winRegistrationOurs ?? ((xml, expectedUserIds) =>
234
254
  windowsTaskRegistrationIsOurs(xml, expectedUserIds, (deps.winLauncherPath ?? windowsLauncherVbsPath)())),
@@ -367,12 +387,6 @@ function inspectWindowsSchedulerManager(
367
387
  approvedPid: number,
368
388
  io: ResolvedWindowsDeps,
369
389
  ): GuardedManagerTarget {
370
- let xml: string;
371
- try {
372
- xml = io.winTaskXml();
373
- } catch {
374
- return unknown("the Task Scheduler registration could not be read");
375
- }
376
390
  const processes = io.winProcs();
377
391
  if (processes === null) return unknown("the Windows process list could not be read");
378
392
  const paths = wrapperPaths(io);
@@ -381,16 +395,34 @@ function inspectWindowsSchedulerManager(
381
395
  if (unreadableAncestorWrapperPids(approvedPid, processes).length > 0) {
382
396
  return unknown("a possible scheduler wrapper has an unreadable command line");
383
397
  }
398
+ // A registration that cannot run anything owes only the stray-wrapper check.
399
+ // That covers BOTH a not-running task and a task proven deleted between the
400
+ // caller's presence probe and this inspection: in either case nothing
401
+ // registered can respawn the proxy, while a surviving wrapper still can —
402
+ // schtasks /end would report success on the inert task and leave it alive.
403
+ const inertVerdict = (): GuardedManagerTarget => strays.length === 0
404
+ ? { kind: "absent" }
405
+ : unknown("a surviving scheduler wrapper could not be tied to the registered task");
384
406
  const state = io.winTaskState();
385
- if (state === "unknown") return unknown("the registered task's running state could not be proven");
386
- if (state === "not-running") {
387
- // A task with no running instance owes nothing ONLY when no wrapper survives.
388
- // A live wrapper outside a task instance — whether it parents the approved
389
- // PID or not — is unaccounted supervision: schtasks /end would report success
390
- // on the inert task while the wrapper stayed alive to respawn the proxy.
391
- return strays.length === 0
392
- ? { kind: "absent" }
393
- : unknown("a surviving scheduler wrapper could not be tied to the registered task");
407
+ if (state === "unknown") {
408
+ // Missing vs unreadable: the state query also fails for a task deleted
409
+ // between probes. A re-probe that proves absence is the inert verdict; a
410
+ // registration that persists or stays unanswerable keeps failing closed.
411
+ return io.winTaskProbe().status === "absent"
412
+ ? inertVerdict()
413
+ : unknown("the registered task's running state could not be proven");
414
+ }
415
+ if (state === "not-running") return inertVerdict();
416
+ // Only the running path needs the registered definition: an unreadable
417
+ // registration cannot prove the task is ours, but it must not hold an
418
+ // otherwise-provable inert verdict unknown.
419
+ let xml: string;
420
+ try {
421
+ xml = io.winTaskXml();
422
+ } catch {
423
+ return io.winTaskProbe().status === "absent"
424
+ ? inertVerdict()
425
+ : unknown("the Task Scheduler registration could not be read");
394
426
  }
395
427
  if (!io.winRegistrationOurs(xml, io.winTaskUserIds())) {
396
428
  return unknown("the registered task is not a recognized OpenCodex definition");
@@ -520,8 +552,17 @@ export function observeWindowsGuardedManagerStopped(
520
552
  if (native === "started") return "active";
521
553
  if (scheduler.status === "present") {
522
554
  const state = (deps.winTaskState ?? (live ? () => windowsScheduledTaskState() : () => "unknown"))();
523
- if (state === "unknown") return "unknown";
524
555
  if (state === "running") return "active";
556
+ if (state === "unknown") {
557
+ // The state read also fails for a task deleted between the presence probe
558
+ // and this observation. A registration that is GONE cannot respawn
559
+ // anything, so re-probe before failing closed — only a persisting or
560
+ // unanswerable task stays unknown. Proven absence still owes the
561
+ // unreadable-former-manager check below.
562
+ if ((deps.scheduler ?? probeWindowsSchedulerTask)().status !== "absent") {
563
+ return "unknown";
564
+ }
565
+ }
525
566
  }
526
567
  if (deps.formerManagerPid !== undefined && unreadableWrappersOfManager(deps.formerManagerPid, procs).length > 0) {
527
568
  return "unknown";
@@ -47,11 +47,20 @@ export function decodeSchtasksOutput(
47
47
  return decodeWindowsTextBytes(buffer, options);
48
48
  }
49
49
 
50
+ /**
51
+ * Every schtasks operation is sub-second on a healthy system; a wedged Task
52
+ * Scheduler service must not block a guarded stop forever. A killed command
53
+ * throws into the same unreadable/failed classification a nonzero exit would —
54
+ * the guarded verdicts stay fail-closed either way.
55
+ */
56
+ const SCHTASKS_TIMEOUT_MS = 15_000;
57
+
50
58
  function runFile(file: string, args: string[]): string {
51
59
  const buffer = execFileSync(file, args, {
52
60
  encoding: "buffer",
53
61
  stdio: ["ignore", "pipe", "pipe"],
54
62
  windowsHide: true,
63
+ timeout: SCHTASKS_TIMEOUT_MS,
55
64
  }) as Buffer;
56
65
  return decodeSchtasksOutput(buffer);
57
66
  }
@@ -173,17 +182,27 @@ export function schtasksErrorDetail(error: unknown): string {
173
182
  return error instanceof Error ? error.message : String(error);
174
183
  }
175
184
 
176
- /** True when a schtasks CSV listing line refers to the given task name. */
185
+ /**
186
+ * True when a schtasks CSV listing line refers to the given task name.
187
+ * "Given task name" means the task of that name in the ROOT folder — the only
188
+ * one every `/tn` operation here can address; a same-named task inside a task
189
+ * folder is a different registration and must not count.
190
+ */
177
191
  export function windowsSchedulerCsvIncludesTask(csv: string, taskName: string): boolean {
178
192
  const needle = taskName.toLowerCase();
179
193
  for (const line of csv.split(/\r?\n/)) {
180
194
  const lower = line.toLowerCase();
181
195
  if (!lower.includes(needle)) continue;
182
- // Prefer exact CSV field matches ("\TaskName" / "TaskName") before a substring hit.
196
+ // Prefer exact CSV field matches ("\TaskName" / "TaskName") before a looser
197
+ // boundary hit. A bare backslash is NOT a boundary: `folder\name` is a path
198
+ // separator, not the root task — every /tn operation here resolves a bare
199
+ // name in the root folder only, so `,\Tools\opencodex-proxy,` is a different
200
+ // registration this probe can neither read nor stop. The optional `\\?`
201
+ // covers the unquoted root form `,\opencodex-proxy,` only.
183
202
  if (
184
203
  lower.includes(`"\\${needle}"`)
185
204
  || lower.includes(`"${needle}"`)
186
- || new RegExp(`(^|[,\\\\])${needle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}([,"]|$)`).test(lower)
205
+ || new RegExp(`(^|,)\\\\?${needle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}([,"]|$)`).test(lower)
187
206
  ) {
188
207
  return true;
189
208
  }
@@ -4,6 +4,7 @@ import { TASK, windowsServiceScriptPath, windowsLauncherVbsPath, windowsTaskXmlP
4
4
  import { windowsWscript } from "./windows-scheduler";
5
5
  import { join } from "node:path";
6
6
  import { BUN_RUNTIME_PATH_ENV, BUN_RUNTIME_SOURCE_ENV } from "../lib/bun-runtime";
7
+ import { REAL_BUN_MIN_BYTES } from "../lib/bun-binary-validator.mjs";
7
8
  import { serviceApiTokenFilePath } from "../lib/service-secrets";
8
9
  import { windowsEnvIndirectBatchPathList, windowsEnvIndirectBatchValue } from "../lib/win-paths";
9
10
  import { cachedCurrentWindowsIdentity, resolveCurrentWindowsPrincipal, WINDOWS_PRINCIPAL_LOOKUP_TIMEOUT_MS } from "../lib/windows-user-principal";
@@ -54,6 +55,10 @@ function taskXmlRunLevelAcceptable(principal: string): boolean {
54
55
  return value === "leastprivilege" || value === "highestavailable";
55
56
  }
56
57
 
58
+ /**
59
+ * Batch wrapper the scheduled task runs: restarts the proxy on exit, restores a transactional-update
60
+ * backup when the install is gone, and waits while bundled Bun is still npm's placeholder.
61
+ */
57
62
  export function buildWindowsServiceScript(
58
63
  entry = cliEntry(),
59
64
  port = resolveServiceListenPort(),
@@ -101,17 +106,21 @@ export function buildWindowsServiceScript(
101
106
  'if not exist "%OCX_BUN%" (',
102
107
  " call :restore_backup",
103
108
  ")",
104
- 'if not exist "%OCX_BUN%" (',
105
- ' >>"%OCX_SERVICE_LOG%" echo [%DATE% %TIME%] installation is incomplete: bundled Bun is missing; reinstall opencodex, then run ocx service repair',
106
- " exit /b 3",
107
- ")",
109
+ // Locale dates can contain parentheses. Keep timestamp expansion outside blocks.
110
+ 'if not exist "%OCX_BUN%" goto bun_missing',
111
+ // An in-place npm install extracts the bun package's tiny placeholder before its postinstall
112
+ // swaps in the real binary. Executing it fails with exit 216 and, in an interactive session,
113
+ // a modal "Unsupported 16-Bit Application" dialog that blocks this loop until dismissed.
114
+ // The install can also remove the file between the exist check and this read; an empty size
115
+ // would turn the comparison into a syntax error that ends the wrapper.
116
+ 'set "OCX_BUN_BYTES="',
117
+ 'for %%F in ("%OCX_BUN%") do set "OCX_BUN_BYTES=%%~zF"',
118
+ 'if not defined OCX_BUN_BYTES goto bun_not_ready',
119
+ `if %OCX_BUN_BYTES% LSS ${REAL_BUN_MIN_BYTES} goto bun_not_ready`,
108
120
  cli ? 'if not exist "%OCX_CLI%" (' : null,
109
121
  cli ? " call :restore_backup" : null,
110
122
  cli ? ")" : null,
111
- cli ? 'if not exist "%OCX_CLI%" (' : null,
112
- cli ? ' >>"%OCX_SERVICE_LOG%" echo [%DATE% %TIME%] installation is incomplete: CLI entry is missing; reinstall opencodex, then run ocx service repair' : null,
113
- cli ? " exit /b 3" : null,
114
- cli ? ")" : null,
123
+ cli ? 'if not exist "%OCX_CLI%" goto cli_missing' : null,
115
124
  cli ? `"%OCX_BUN%" "%OCX_CLI%" start --port ${port} >>"%OCX_SERVICE_LOG%" 2>&1` : `"%OCX_BUN%" start --port ${port} >>"%OCX_SERVICE_LOG%" 2>&1`,
116
125
  // Stop commands kill the wrapper; a zero child exit alone is not a stop request.
117
126
  `if "%ERRORLEVEL%"=="${WINDOWS_WRAPPER_STAY_OUT_EXIT_CODE}" goto stopped`,
@@ -123,6 +132,16 @@ export function buildWindowsServiceScript(
123
132
  ":stopped",
124
133
  "endlocal",
125
134
  "exit /b 0",
135
+ ":bun_not_ready",
136
+ '>>"%OCX_SERVICE_LOG%" echo [%DATE% %TIME%] bundled Bun is not ready (%OCX_BUN_BYTES% bytes, npm placeholder or mid-install); waiting for its postinstall, retrying in 5s - if this persists, reinstall opencodex with bun scripts allowed',
137
+ "ping -n 6 127.0.0.1 >nul",
138
+ "goto loop",
139
+ ":bun_missing",
140
+ '>>"%OCX_SERVICE_LOG%" echo [%DATE% %TIME%] installation is incomplete: bundled Bun is missing; reinstall opencodex, then run ocx service repair',
141
+ "exit /b 3",
142
+ cli ? ":cli_missing" : null,
143
+ cli ? '>>"%OCX_SERVICE_LOG%" echo [%DATE% %TIME%] installation is incomplete: CLI entry is missing; reinstall opencodex, then run ocx service repair' : null,
144
+ cli ? "exit /b 3" : null,
126
145
  "",
127
146
  // #1942/#1849: a power loss mid-swap leaves the live package dir missing/broken and
128
147
  // a sibling .ocx-backup-* holding the previous version. This wrapper lives OUTSIDE
@@ -135,13 +154,16 @@ export function buildWindowsServiceScript(
135
154
  ' if exist "%OCX_PKG_DIR%" rmdir /s /q "%OCX_PKG_DIR%" 2>nul',
136
155
  ' move "%OCX_PKG_DIR%\\..\\%%B\\opencodex" "%OCX_PKG_DIR%" >nul 2>&1',
137
156
  ' if exist "%OCX_PKG_DIR%\\package.json" (',
138
- ' >>"%OCX_SERVICE_LOG%" echo [%DATE% %TIME%] restored previous install from %%B',
139
- " goto :eof",
157
+ ' set "OCX_RESTORED_BACKUP=%%B"',
158
+ " goto backup_restored",
140
159
  " )",
141
160
  " )",
142
161
  ")",
143
162
  '>>"%OCX_SERVICE_LOG%" echo [%DATE% %TIME%] no restorable backup found',
144
163
  "goto :eof",
164
+ ":backup_restored",
165
+ '>>"%OCX_SERVICE_LOG%" echo [%DATE% %TIME%] restored previous install from %OCX_RESTORED_BACKUP%',
166
+ "goto :eof",
145
167
  ].filter((line): line is string => Boolean(line));
146
168
  return `${lines.join("\r\n")}\r\n`;
147
169
  }