@bitkyc08/opencodex 2.67.0 → 2.68.0-preview.20260927

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 (257) hide show
  1. package/bin/ocx.mjs +3 -0
  2. package/gui/dist/assets/App-B5-cAEd2.js +51 -0
  3. package/gui/dist/assets/App-DCBismRi.css +1 -0
  4. package/gui/dist/assets/Tray-DFnMiyD0.js +1 -0
  5. package/gui/dist/assets/{index-DtNmX7hW.css → index-BDUBS8PW.css} +1 -1
  6. package/gui/dist/assets/index-BcXblvem.js +86 -0
  7. package/gui/dist/assets/quota-summary-i84eOU3o.js +1 -0
  8. package/gui/dist/index.html +21 -2
  9. package/package.json +1 -1
  10. package/src/adapters/anthropic-image-guard.ts +13 -1
  11. package/src/adapters/anthropic-image-normalize.ts +3 -2
  12. package/src/adapters/anthropic-output-schema.ts +36 -0
  13. package/src/adapters/anthropic.ts +10 -2
  14. package/src/adapters/base.ts +7 -0
  15. package/src/adapters/codebuddy/live-models.ts +180 -0
  16. package/src/adapters/coding-agent/protocol.ts +91 -12
  17. package/src/adapters/coding-agent/turn.ts +32 -27
  18. package/src/adapters/devin/cloud-direct/index.ts +1 -0
  19. package/src/adapters/devin/cloud-direct/stated-reset-retry.ts +18 -4
  20. package/src/adapters/devin.ts +9 -6
  21. package/src/adapters/google-http.ts +4 -4
  22. package/src/adapters/google.ts +93 -3
  23. package/src/adapters/kiro/adapter.ts +6 -2
  24. package/src/adapters/kiro/stream.ts +12 -2
  25. package/src/adapters/kiro/usage.ts +3 -1
  26. package/src/adapters/kiro-errors.ts +11 -1
  27. package/src/adapters/kiro-events.ts +27 -1
  28. package/src/adapters/kiro-refusal.ts +30 -0
  29. package/src/adapters/kiro-retry.ts +71 -31
  30. package/src/adapters/openai-chat/deepseek-artifact-schema.ts +45 -0
  31. package/src/adapters/openai-chat/messages.ts +31 -4
  32. package/src/adapters/openai-chat/serialized-tool-call-content.ts +52 -7
  33. package/src/adapters/openai-chat/tool-call-id-remint.ts +65 -0
  34. package/src/adapters/openai-chat/tool-schema.ts +6 -2
  35. package/src/adapters/openai-chat.ts +2 -2
  36. package/src/adapters/openai-responses/muse-tool-choice.ts +31 -0
  37. package/src/adapters/openai-responses/passthrough.ts +9 -3
  38. package/src/adapters/opencode-go-additional-tools.ts +19 -10
  39. package/src/adapters/physical-send.ts +10 -3
  40. package/src/adapters/registry.ts +2 -1
  41. package/src/adapters/responses-tool-schema.ts +31 -3
  42. package/src/adapters/run-turn-queue.ts +63 -23
  43. package/src/adapters/unique-tool-call-ids.ts +63 -0
  44. package/src/adapters/xai-web-search.ts +32 -2
  45. package/src/bridge/sse.ts +14 -2
  46. package/src/chat/inbound.ts +20 -6
  47. package/src/claude/agents-inject.ts +4 -0
  48. package/src/claude/desktop-3p.ts +53 -13
  49. package/src/claude/desktop-profile.ts +41 -4
  50. package/src/claude/inbound-model-options.ts +14 -2
  51. package/src/claude/inbound.ts +1 -1
  52. package/src/claude/intercept/connect-proxy.ts +17 -1
  53. package/src/claude/intercept/local-ca.ts +7 -1
  54. package/src/claude/message-threads.ts +28 -0
  55. package/src/cli/account-api.ts +13 -0
  56. package/src/cli/account-auth.ts +49 -2
  57. package/src/cli/account-extended.ts +4 -2
  58. package/src/cli/account.ts +4 -2
  59. package/src/cli/capabilities.ts +17 -0
  60. package/src/cli/claude.ts +3 -1
  61. package/src/cli/dispatch.ts +62 -5
  62. package/src/cli/index.ts +84 -79
  63. package/src/cli/minimax.ts +4 -2
  64. package/src/cli/opencode.ts +6 -1
  65. package/src/cli/restart-handoff.ts +110 -0
  66. package/src/cli/status.ts +51 -0
  67. package/src/client/connect.ts +34 -15
  68. package/src/client/link-ingress.ts +102 -0
  69. package/src/client/link-join.ts +37 -16
  70. package/src/client/link-relay.ts +228 -45
  71. package/src/client/link-state.ts +54 -0
  72. package/src/client/link-status.ts +39 -0
  73. package/src/client/link-teardown.ts +2 -2
  74. package/src/client/link-tunnel.ts +615 -86
  75. package/src/client/machine-api.ts +10 -4
  76. package/src/client/machine-listener.ts +57 -10
  77. package/src/client/runtime.ts +210 -35
  78. package/src/clients/config-export/constants.ts +1 -12
  79. package/src/clients/config-export/contracts.ts +2 -0
  80. package/src/clients/config-export/model-metadata.ts +9 -3
  81. package/src/clients/config-export/omp.ts +1 -1
  82. package/src/clients/config-export/zcode-store.ts +2 -2
  83. package/src/clients/config-export.ts +36 -3
  84. package/src/codex/app-server-processes.ts +32 -0
  85. package/src/codex/app-server-restart-service.ts +20 -3
  86. package/src/codex/catalog/access-programs.ts +50 -0
  87. package/src/codex/catalog/build-entries.ts +13 -0
  88. package/src/codex/catalog/derive-entry.ts +1 -0
  89. package/src/codex/catalog/model-hints.ts +11 -0
  90. package/src/codex/catalog/parsing.ts +6 -2
  91. package/src/codex/catalog/provider-models.ts +81 -0
  92. package/src/codex/catalog/retained-sync.ts +3 -0
  93. package/src/codex/codex-write-lock.ts +2 -2
  94. package/src/codex/convergence.ts +2 -0
  95. package/src/codex/desired-state.ts +14 -4
  96. package/src/codex/home.ts +21 -3
  97. package/src/codex/inject/restore.ts +17 -0
  98. package/src/codex/inject/routing-classify.ts +3 -2
  99. package/src/codex/inject-coordination.ts +4 -1
  100. package/src/codex/inject.ts +8 -1
  101. package/src/codex/journal.ts +6 -2
  102. package/src/codex/management-convergence.ts +9 -0
  103. package/src/codex/model-entitlements.ts +28 -5
  104. package/src/codex/routing/idle-window.ts +58 -0
  105. package/src/codex/routing-drift.ts +134 -0
  106. package/src/codex/routing-healer.ts +419 -0
  107. package/src/codex/routing.ts +10 -0
  108. package/src/codex/runtime.ts +188 -57
  109. package/src/codex/sibling-handoff.ts +71 -0
  110. package/src/codex/sibling-start.ts +139 -0
  111. package/src/codex/sync.ts +5 -3
  112. package/src/combos/failover.ts +32 -5
  113. package/src/combos/request.ts +11 -3
  114. package/src/combos/reset-window.ts +10 -5
  115. package/src/combos/resolve.ts +36 -6
  116. package/src/config/diagnostics.ts +2 -1
  117. package/src/config/load-degrade.ts +2 -0
  118. package/src/config/paths.ts +7 -1
  119. package/src/config/process-state.ts +10 -1
  120. package/src/config/schema/compaction-recovery.ts +15 -0
  121. package/src/config/schema/config-schema.ts +2 -0
  122. package/src/config/schema/leaf-validators.ts +9 -3
  123. package/src/generated/compatibility-version.json +360 -204
  124. package/src/images/loop.ts +47 -32
  125. package/src/integrations/catalog-refresh.ts +4 -0
  126. package/src/integrations/omp-yaml-source.ts +1 -0
  127. package/src/lib/claude-request-projection.ts +101 -0
  128. package/src/lib/local-account-switch-capability.ts +48 -0
  129. package/src/lib/local-upstream.ts +146 -0
  130. package/src/lib/package-tree-integrity.ts +25 -3
  131. package/src/lib/package-tree-retarget.ts +130 -0
  132. package/src/lib/process-control.ts +4 -1
  133. package/src/lib/system-restart-contract.ts +106 -0
  134. package/src/lib/upstream-retry.ts +57 -1
  135. package/src/link/ports.ts +9 -0
  136. package/src/link/ssh-argv.ts +16 -0
  137. package/src/link/ssh-runner.ts +70 -2
  138. package/src/link/tunnel-state.ts +58 -9
  139. package/src/oauth/account-quota-rank.ts +29 -18
  140. package/src/oauth/generic-account-failover.ts +179 -22
  141. package/src/oauth/index.ts +17 -4
  142. package/src/oauth/kiro-account-load.ts +107 -0
  143. package/src/oauth/kiro-device-login.ts +312 -0
  144. package/src/oauth/kiro-terminal-failover.ts +28 -0
  145. package/src/oauth/login-flow-state.ts +7 -1
  146. package/src/oauth/pool-settings-capability.ts +23 -14
  147. package/src/oauth/store.ts +56 -3
  148. package/src/oauth/types.ts +4 -0
  149. package/src/plugins/loader.ts +350 -0
  150. package/src/plugins/upstream-hooks.ts +119 -0
  151. package/src/protocols/encoders/adapter-events.ts +9 -2
  152. package/src/providers/account-quota-disk.ts +42 -4
  153. package/src/providers/codebuddy-models.ts +6 -2
  154. package/src/providers/command-code-efforts.ts +33 -10
  155. package/src/providers/kiro-account-state-disk.ts +44 -0
  156. package/src/providers/kiro-model-catalog.ts +162 -0
  157. package/src/providers/kiro-models.ts +5 -4
  158. package/src/providers/kiro-quota-metrics.ts +35 -0
  159. package/src/providers/kiro-usage.ts +118 -14
  160. package/src/providers/quota/account-cache.ts +43 -7
  161. package/src/providers/quota/antigravity.ts +8 -3
  162. package/src/providers/quota/kiro-account-probe.ts +12 -0
  163. package/src/providers/quota/vendor-probes-key.ts +31 -26
  164. package/src/providers/quota/vendor-probes-oauth.ts +16 -7
  165. package/src/providers/quota-types.ts +14 -0
  166. package/src/providers/quota.ts +21 -21
  167. package/src/providers/registry/entries-extended.ts +5 -3
  168. package/src/providers/request-pacing.ts +410 -12
  169. package/src/remote-control/workspace-codex-runtime.ts +4 -3
  170. package/src/responses/citation-markers.ts +132 -65
  171. package/src/responses/hosted-tool-policy.ts +14 -3
  172. package/src/responses/parser-content.ts +3 -2
  173. package/src/responses/parser.ts +4 -1
  174. package/src/responses/schema.ts +4 -0
  175. package/src/responses/visualization-directives.ts +182 -0
  176. package/src/server/chat-native-sse.ts +24 -12
  177. package/src/server/chat-native.ts +137 -68
  178. package/src/server/claude-messages.ts +147 -12
  179. package/src/server/index/link-listener.ts +7 -0
  180. package/src/server/index/optional-listeners.ts +4 -1
  181. package/src/server/index/package-tree-guard.ts +35 -16
  182. package/src/server/index/serve-options.ts +4 -2
  183. package/src/server/index/startup-warnings.ts +6 -0
  184. package/src/server/index.ts +7 -7
  185. package/src/server/inference/client-encoder-delivery.ts +3 -0
  186. package/src/server/inference/context.ts +30 -2
  187. package/src/server/local-account-switch-auth.ts +79 -0
  188. package/src/server/management/agent-settings-routes.ts +11 -3
  189. package/src/server/management/config-routes.ts +28 -11
  190. package/src/server/management/context.ts +2 -0
  191. package/src/server/management/link-routes.ts +123 -39
  192. package/src/server/management/logs-usage-routes.ts +9 -42
  193. package/src/server/management/model-rows.ts +1 -0
  194. package/src/server/management/oauth-account-routes.ts +67 -12
  195. package/src/server/management/route-registry.ts +5 -5
  196. package/src/server/management/sibling-guard.ts +60 -0
  197. package/src/server/management/storage-log-guard-routes.ts +72 -6
  198. package/src/server/management/system-restart.ts +92 -135
  199. package/src/server/management/system-routes.ts +1 -1
  200. package/src/server/management-api.ts +31 -3
  201. package/src/server/management-auth.ts +3 -0
  202. package/src/server/port-reclaim.ts +111 -1
  203. package/src/server/proxy-liveness.ts +16 -0
  204. package/src/server/relay.ts +16 -15
  205. package/src/server/request-log-filter.ts +28 -27
  206. package/src/server/request-log.ts +7 -3
  207. package/src/server/request-metrics.ts +28 -0
  208. package/src/server/responses/adapter-continuation.ts +151 -21
  209. package/src/server/responses/adapter-delivery.ts +21 -7
  210. package/src/server/responses/adapter-dispatch.ts +239 -51
  211. package/src/server/responses/codex-ws-pool.ts +5 -4
  212. package/src/server/responses/codex-ws-request.ts +4 -1
  213. package/src/server/responses/compact.ts +8 -4
  214. package/src/server/responses/compaction-recovery-policy.ts +116 -0
  215. package/src/server/responses/compaction-recovery.ts +330 -0
  216. package/src/server/responses/core-combo-failure.ts +1 -0
  217. package/src/server/responses/core-combo.ts +5 -1
  218. package/src/server/responses/core-lifetime.ts +22 -0
  219. package/src/server/responses/core-options.ts +16 -1
  220. package/src/server/responses/core.ts +14 -4
  221. package/src/server/responses/empty-completion-guard.ts +2 -0
  222. package/src/server/responses/fetch-helpers.ts +82 -31
  223. package/src/server/responses/passthrough-delivery.ts +3 -2
  224. package/src/server/responses/passthrough-dispatch.ts +12 -2
  225. package/src/server/responses/passthrough-execution.ts +2 -2
  226. package/src/server/responses/request-send-budget.ts +21 -5
  227. package/src/server/responses/request-transport.ts +116 -18
  228. package/src/server/responses/reset-replay.ts +32 -0
  229. package/src/server/responses/run-turn-execution.ts +136 -19
  230. package/src/server/responses/sidecar-execution.ts +41 -8
  231. package/src/server/responses/terminal-guard.ts +2 -0
  232. package/src/server/responses/ws-upstream.ts +44 -4
  233. package/src/server/restart-replacement.ts +382 -0
  234. package/src/server/startup-health-cache.ts +23 -4
  235. package/src/server/stop-teardown.ts +35 -1
  236. package/src/server/system-env.ts +3 -0
  237. package/src/service/windows-taskxml.ts +11 -7
  238. package/src/service/windows-wrapper-exit.ts +8 -0
  239. package/src/stall-timeout.ts +35 -10
  240. package/src/storage/policy-job.ts +4 -0
  241. package/src/storage/scanner.ts +177 -29
  242. package/src/storage/storage-mutation-coordinator.ts +17 -0
  243. package/src/tray/windows-tray.ps1 +50 -23
  244. package/src/types/config.ts +4 -0
  245. package/src/types/provider.ts +5 -1
  246. package/src/types/request.ts +13 -2
  247. package/src/update/index.ts +3 -1
  248. package/src/update/job.ts +6 -2
  249. package/src/update/mise-launcher-target.ts +100 -0
  250. package/src/update/worker-launch.ts +46 -0
  251. package/src/usage/log.ts +2 -0
  252. package/src/web-search/loop.ts +5 -2
  253. package/gui/dist/assets/App-BJsT8Icc.css +0 -1
  254. package/gui/dist/assets/App-D3pNGiN4.js +0 -51
  255. package/gui/dist/assets/Tray-yb05wCjb.js +0 -1
  256. package/gui/dist/assets/index-CouvvtMV.js +0 -86
  257. package/gui/dist/assets/tray-data-CYGdjCJ7.js +0 -1
@@ -0,0 +1,382 @@
1
+ /**
2
+ * Spawning the detached `ocx start` that replaces this process: the parent side of a restart handoff.
3
+ *
4
+ * Shared by the dashboard drain-and-restart (`src/server/management/system-restart.ts`, which is also
5
+ * how a join into a Child restarts) and the client runtime's standalone recycle
6
+ * (`src/client/runtime.ts`). Deliberately lean: the client runtime loads it only at recycle time and
7
+ * must not pull the server lifecycle graph in with it.
8
+ *
9
+ * Every replacement carries `OCX_RESTART_PARENT_PID`, so a replacement that still finds this process
10
+ * answering on the port waits for it instead of refusing it (`src/cli/restart-handoff.ts`).
11
+ *
12
+ * A handoff that waits for health (the drain completed) retries a replacement that exits before it
13
+ * answers, at most {@link REPLACEMENT_EARLY_EXIT_RETRIES} more times inside the one readiness
14
+ * budget: the port a restart hands over can still be draining, and one refused start used to end the
15
+ * whole handoff. A parent-exit handoff resolves once the child spawned and is never retried; the
16
+ * replacement's own parent wait and port reclaim cover it. A spawn error is not retried; it does not
17
+ * change between attempts.
18
+ *
19
+ * The replacement's stdout and stderr go to `<configDir>/restart-handoff.log` instead of nowhere, so
20
+ * a switch that failed can be diagnosed. The file is private (0600, created in the private config
21
+ * directory, never opened through a symlink) and bounded at {@link RESTART_HANDOFF_LOG_MAX_BYTES} on
22
+ * both sides: a handoff empties it before it opens it, and the replacement, which keeps writing to
23
+ * it for its whole life, checks it once a minute ({@link RESTART_HANDOFF_LOG_ENV},
24
+ * {@link armRestartHandoffLogCap}). The parent writes only timestamps, pids, ports, attempt counts,
25
+ * exit codes and errno codes, never an environment value; the replacement writes what an `ocx start`
26
+ * prints to a terminal. A log that cannot be opened never blocks the handoff: the output is
27
+ * discarded as before.
28
+ */
29
+ import { spawn, type ChildProcess, type SpawnOptions } from "node:child_process";
30
+ import { closeSync, constants, fchmodSync, fstatSync, ftruncateSync, lstatSync, openSync, writeSync } from "node:fs";
31
+ import { join } from "node:path";
32
+ import { getConfigDir } from "../config/paths";
33
+ import { withProcessRuntimeProvenance } from "../lib/bun-runtime";
34
+ import { recordOwnedConfigPath } from "../lib/config-ownership";
35
+ import { selfLaunchArgv } from "../lib/self-launch-argv";
36
+ import { REPLACEMENT_READY_TIMEOUT_MS, withRestartParentMarker } from "../lib/system-restart-contract";
37
+ import { findLiveProxy } from "./proxy-liveness";
38
+
39
+ export const RESTART_HANDOFF_LOG_FILENAME = "restart-handoff.log";
40
+ export const RESTART_HANDOFF_LOG_MAX_BYTES = 256 * 1024;
41
+ /**
42
+ * Set to `1` on a replacement whose stdout and stderr are the handoff log. `handleStart` consumes it
43
+ * and arms {@link armRestartHandoffLogCap}, so the file stays bounded after the parent that opened it
44
+ * is gone. A hand-set value only makes that start cap the private handoff log.
45
+ */
46
+ export const RESTART_HANDOFF_LOG_ENV = "OCX_RESTART_HANDOFF_LOG";
47
+ /** How often a replacement writing into the handoff log checks its size: one lstat per tick. */
48
+ export const RESTART_HANDOFF_LOG_CHECK_MS = 60_000;
49
+ /** Respawns after an early exit, on top of the first attempt. */
50
+ export const REPLACEMENT_EARLY_EXIT_RETRIES = 2;
51
+ const REPLACEMENT_RETRY_DELAY_MS = 1_000;
52
+ const REPLACEMENT_READY_POLL_MS = 150;
53
+
54
+ export interface ReplacementReadinessIo {
55
+ findLive?: typeof findLiveProxy;
56
+ now?: () => number;
57
+ sleep?: (ms: number) => Promise<void>;
58
+ /** One absolute deadline for every attempt of a handoff; defaults to now + the readiness budget. */
59
+ deadlineAt?: number;
60
+ /** Stops polling once the attempt it serves has already settled (the replacement exited). */
61
+ cancelled?: () => boolean;
62
+ }
63
+
64
+ export async function waitForReplacementReady(
65
+ expectedPid: number | undefined,
66
+ parentPid: number,
67
+ expectedPort: number | undefined,
68
+ io: ReplacementReadinessIo = {},
69
+ ): Promise<boolean> {
70
+ const findLive = io.findLive ?? findLiveProxy;
71
+ const now = io.now ?? Date.now;
72
+ const sleep = io.sleep ?? Bun.sleep;
73
+ const deadline = io.deadlineAt ?? now() + REPLACEMENT_READY_TIMEOUT_MS;
74
+ while (now() < deadline) {
75
+ if (io.cancelled?.()) return false;
76
+ try {
77
+ const live = await findLive({
78
+ deadlineAt: deadline,
79
+ nowFn: now,
80
+ sleepFn: sleep,
81
+ });
82
+ // A probe that began within budget can still return after it. Never accept
83
+ // delayed health as proof once the shared absolute handoff budget expired.
84
+ if (now() >= deadline) return false;
85
+ if (
86
+ live
87
+ && live.pid !== null
88
+ && live.pid !== parentPid
89
+ && (expectedPid === undefined || live.pid === expectedPid)
90
+ && (expectedPort === undefined || live.port === expectedPort)
91
+ ) {
92
+ return true;
93
+ }
94
+ } catch {
95
+ // A not-yet-bound replacement is indistinguishable from a transient
96
+ // liveness failure here; keep polling inside the one bounded window.
97
+ }
98
+ const remainingMs = deadline - now();
99
+ if (remainingMs <= 0) break;
100
+ await sleep(Math.min(REPLACEMENT_READY_POLL_MS, remainingMs));
101
+ }
102
+ return false;
103
+ }
104
+
105
+ export interface RestartHandoffLog {
106
+ readonly fd: number;
107
+ note(line: string): void;
108
+ close(): void;
109
+ }
110
+
111
+ export function restartHandoffLogPath(configDir: string = getConfigDir()): string {
112
+ return join(configDir, RESTART_HANDOFF_LOG_FILENAME);
113
+ }
114
+
115
+ /**
116
+ * Empty the handoff log once it has reached the cap; below the cap this is one lstat. The truncation
117
+ * goes through a fresh non-append descriptor, never through a symlink, so it also works where an
118
+ * append handle cannot truncate (Windows). Every writer's append descriptor carries on at the new end.
119
+ * Returns true when it emptied the file.
120
+ */
121
+ export function emptyRestartHandoffLogIfFull(path: string, now: () => number = Date.now): boolean {
122
+ let fd: number | undefined;
123
+ try {
124
+ const stat = lstatSync(path);
125
+ if (!stat.isFile() || stat.size < RESTART_HANDOFF_LOG_MAX_BYTES) return false;
126
+ fd = openSync(path, constants.O_WRONLY | (constants.O_NOFOLLOW ?? 0));
127
+ if (!fstatSync(fd).isFile()) return false;
128
+ ftruncateSync(fd, 0);
129
+ writeSync(fd, `[${new Date(now()).toISOString()}] log emptied at the ${RESTART_HANDOFF_LOG_MAX_BYTES / 1024} KiB cap\n`);
130
+ return true;
131
+ } catch {
132
+ return false;
133
+ } finally {
134
+ if (fd !== undefined) {
135
+ try { closeSync(fd); } catch { /* best effort */ }
136
+ }
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Open the handoff log for appending: private, never through a symlink, and emptied first when it
142
+ * has reached the cap. Returns null when it cannot be opened safely.
143
+ */
144
+ export function openRestartHandoffLog(path: string, now: () => number = Date.now): RestartHandoffLog | null {
145
+ emptyRestartHandoffLogIfFull(path, now);
146
+ let fd: number | undefined;
147
+ try {
148
+ // O_NOFOLLOW turns a planted symlink into an open error instead of a write somewhere else.
149
+ const flags = constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | (constants.O_NOFOLLOW ?? 0);
150
+ fd = openSync(path, flags, 0o600);
151
+ const stat = fstatSync(fd);
152
+ if (!stat.isFile()) {
153
+ closeSync(fd);
154
+ return null;
155
+ }
156
+ if (process.platform !== "win32") fchmodSync(fd, 0o600);
157
+ const handle = fd;
158
+ return {
159
+ fd: handle,
160
+ note(line: string) {
161
+ try { writeSync(handle, `[${new Date(now()).toISOString()}] ${line}\n`); } catch { /* diagnostics only */ }
162
+ },
163
+ close() {
164
+ try { closeSync(handle); } catch { /* already closed */ }
165
+ },
166
+ };
167
+ } catch {
168
+ if (fd !== undefined) {
169
+ try { closeSync(fd); } catch { /* best effort */ }
170
+ }
171
+ return null;
172
+ }
173
+ }
174
+
175
+ export interface RestartHandoffLogCapIo {
176
+ /** The log to bound; default: the config-dir handoff log. */
177
+ path?: string;
178
+ intervalMs?: number;
179
+ }
180
+
181
+ /**
182
+ * The replacement side of the log bound. A replacement whose parent sent its output to the handoff
183
+ * log keeps writing there for its whole life, long after that parent exited, so it checks the file
184
+ * itself from one low-frequency, unref'd timer and empties it at the cap. Consumes
185
+ * {@link RESTART_HANDOFF_LOG_ENV} so no later child inherits it; a start without the flag (every
186
+ * ordinary start) arms nothing. Returns a stop function, or null when nothing was armed.
187
+ */
188
+ export function armRestartHandoffLogCap(
189
+ env: Record<string, string | undefined>,
190
+ io: RestartHandoffLogCapIo = {},
191
+ ): (() => void) | null {
192
+ const flagged = env[RESTART_HANDOFF_LOG_ENV] === "1";
193
+ delete env[RESTART_HANDOFF_LOG_ENV];
194
+ if (!flagged) return null;
195
+ const path = io.path ?? restartHandoffLogPath();
196
+ const timer = setInterval(() => { emptyRestartHandoffLogIfFull(path); }, io.intervalMs ?? RESTART_HANDOFF_LOG_CHECK_MS);
197
+ timer.unref?.();
198
+ return () => { clearInterval(timer); };
199
+ }
200
+
201
+ function openDefaultRestartHandoffLog(now: () => number): RestartHandoffLog | null {
202
+ const configDir = getConfigDir();
203
+ const path = restartHandoffLogPath(configDir);
204
+ // Owned like crash.log, so uninstall removes it with the rest of the config directory.
205
+ try { recordOwnedConfigPath(configDir, path); } catch { /* the log is optional */ }
206
+ return openRestartHandoffLog(path, now);
207
+ }
208
+
209
+ export type ReplacementSpawn = (command: string, args: string[], options: SpawnOptions) => ChildProcess;
210
+
211
+ export interface ReplacementStartIo extends ReplacementReadinessIo {
212
+ spawnChild?: ReplacementSpawn;
213
+ /** The handoff log file; `null` discards the replacement's output. Default: the config-dir log. */
214
+ logPath?: string | null;
215
+ parentPid?: number;
216
+ retryDelayMs?: number;
217
+ }
218
+
219
+ export interface ReplacementStartRequest {
220
+ /** The port the replacement must bind; anything that is not a TCP port starts it unpinned. */
221
+ port?: number;
222
+ /**
223
+ * Wait until the replacement answers before resolving, retrying an early exit. Without it the
224
+ * promise resolves once the child spawned, so the parent can exit and release what the
225
+ * replacement is waiting for.
226
+ */
227
+ waitForHealth: boolean;
228
+ /** The replacement's environment, already prepared by the caller; the parent marker is added here. */
229
+ env: NodeJS.ProcessEnv;
230
+ }
231
+
232
+ type AttemptOutcome =
233
+ | { kind: "spawned" | "ready" | "not-ready"; pid: number | undefined }
234
+ | { kind: "early-exit"; pid: number | undefined; code: number | null; signal: NodeJS.Signals | null };
235
+
236
+ function pinnedPort(port: number | undefined): number | undefined {
237
+ return typeof port === "number" && Number.isFinite(port) && port > 0 && port <= 65535
238
+ ? Math.trunc(port)
239
+ : undefined;
240
+ }
241
+
242
+ function handoffError(code: string): NodeJS.ErrnoException {
243
+ const error = new Error(code) as NodeJS.ErrnoException;
244
+ error.code = code;
245
+ return error;
246
+ }
247
+
248
+ /** Stable, path-free label for a spawn failure (an errno message can carry the OS username). */
249
+ function failureLabel(err: unknown): string {
250
+ if (err && typeof err === "object" && "code" in err) {
251
+ const code = (err as NodeJS.ErrnoException).code;
252
+ if (typeof code === "string" && /^[A-Za-z0-9_]{1,64}$/.test(code)) return code;
253
+ }
254
+ return "spawn_failed";
255
+ }
256
+
257
+ function runReplacementAttempt(
258
+ launchArgs: string[],
259
+ env: NodeJS.ProcessEnv,
260
+ logFd: number | undefined,
261
+ waitForHealth: boolean,
262
+ expectedPort: number | undefined,
263
+ parentPid: number,
264
+ io: ReplacementStartIo,
265
+ ): Promise<AttemptOutcome> {
266
+ return new Promise<AttemptOutcome>((resolve, reject) => {
267
+ let child: ChildProcess;
268
+ try {
269
+ const output = logFd ?? "ignore";
270
+ const options: SpawnOptions = {
271
+ detached: true,
272
+ stdio: ["ignore", output, output],
273
+ windowsHide: true,
274
+ env: withProcessRuntimeProvenance(env),
275
+ };
276
+ child = io.spawnChild
277
+ ? io.spawnChild(process.execPath, launchArgs, options)
278
+ : spawn(process.execPath, launchArgs, options);
279
+ } catch (err) {
280
+ reject(err);
281
+ return;
282
+ }
283
+ let settled = false;
284
+ const cleanup = () => {
285
+ child.off("error", onError);
286
+ child.off("exit", onExit);
287
+ child.off("spawn", onSpawn);
288
+ };
289
+ const settle = (outcome: AttemptOutcome | { error: unknown }) => {
290
+ if (settled) return;
291
+ settled = true;
292
+ cleanup();
293
+ if ("error" in outcome) {
294
+ if (child.exitCode === null && child.signalCode === null) {
295
+ try { child.kill(); } catch { /* best-effort failed-start cleanup */ }
296
+ }
297
+ try { child.unref(); } catch { /* best-effort */ }
298
+ reject(outcome.error);
299
+ return;
300
+ }
301
+ try { child.unref(); } catch { /* best-effort */ }
302
+ resolve(outcome);
303
+ };
304
+ const onError = (err: Error) => { settle({ error: err }); };
305
+ const onExit = (code: number | null, signal: NodeJS.Signals | null) => {
306
+ settle({ kind: "early-exit", pid: child.pid, code, signal });
307
+ };
308
+ const onSpawn = () => {
309
+ if (!waitForHealth) {
310
+ // The replacement may be waiting for resources only this parent's exit releases.
311
+ settle({ kind: "spawned", pid: child.pid });
312
+ return;
313
+ }
314
+ void waitForReplacementReady(child.pid, parentPid, expectedPort, { ...io, cancelled: () => settled }).then(
315
+ // Never kill a live ordinary start at its valid reclaim boundary. Parent exit is the
316
+ // final resource release the child may still be waiting for.
317
+ ready => { settle({ kind: ready ? "ready" : "not-ready", pid: child.pid }); },
318
+ err => { settle({ error: err }); },
319
+ );
320
+ };
321
+ child.once("error", onError);
322
+ child.once("exit", onExit);
323
+ child.once("spawn", onSpawn);
324
+ });
325
+ }
326
+
327
+ /**
328
+ * Spawn this process's replacement `ocx start` and, when asked, wait until it answers. Rejects with
329
+ * the spawn error, or with code `child_exit` once every attempt exited before it was ready.
330
+ */
331
+ export async function spawnReplacementStart(
332
+ request: ReplacementStartRequest,
333
+ io: ReplacementStartIo = {},
334
+ ): Promise<void> {
335
+ const now = io.now ?? Date.now;
336
+ const sleep = io.sleep ?? Bun.sleep;
337
+ const parentPid = io.parentPid ?? process.pid;
338
+ const expectedPort = pinnedPort(request.port);
339
+ const launchArgs = selfLaunchArgv(expectedPort === undefined ? ["start"] : ["start", "--port", String(expectedPort)]);
340
+ const deadlineAt = io.deadlineAt ?? now() + REPLACEMENT_READY_TIMEOUT_MS;
341
+ const attempts = request.waitForHealth ? 1 + REPLACEMENT_EARLY_EXIT_RETRIES : 1;
342
+ const target = expectedPort === undefined ? "an unpinned port" : `port ${expectedPort}`;
343
+ const mode = request.waitForHealth ? "waiting for it to answer" : "exiting first";
344
+ const log = io.logPath === null
345
+ ? null
346
+ : io.logPath === undefined ? openDefaultRestartHandoffLog(now) : openRestartHandoffLog(io.logPath, now);
347
+ const env: NodeJS.ProcessEnv = withRestartParentMarker(request.env, parentPid);
348
+ // Only a replacement that really writes into the log keeps it bounded after this parent is gone.
349
+ if (log) env[RESTART_HANDOFF_LOG_ENV] = "1";
350
+ else delete env[RESTART_HANDOFF_LOG_ENV];
351
+ try {
352
+ for (let attempt = 1; ; attempt += 1) {
353
+ log?.note(`parent pid ${parentPid} starts a replacement on ${target} (attempt ${attempt}/${attempts}, ${mode})`);
354
+ let outcome: AttemptOutcome;
355
+ try {
356
+ outcome = await runReplacementAttempt(
357
+ launchArgs, env, log?.fd, request.waitForHealth, expectedPort, parentPid, { ...io, deadlineAt },
358
+ );
359
+ } catch (error) {
360
+ log?.note(`replacement spawn failed (${failureLabel(error)})`);
361
+ throw error;
362
+ }
363
+ const pid = outcome.pid ?? "unknown";
364
+ if (outcome.kind === "early-exit") {
365
+ log?.note(`replacement pid ${pid} exited before it answered (code ${outcome.code ?? "none"}, signal ${outcome.signal ?? "none"})`);
366
+ const retryDelayMs = io.retryDelayMs ?? REPLACEMENT_RETRY_DELAY_MS;
367
+ if (attempt >= attempts || now() + retryDelayMs >= deadlineAt) throw handoffError("child_exit");
368
+ await sleep(retryDelayMs);
369
+ continue;
370
+ }
371
+ if (outcome.kind === "not-ready") {
372
+ console.warn("Restart replacement is still starting after the readiness window; handing off to it anyway");
373
+ log?.note(`replacement pid ${pid} is still starting after the readiness window; handing off anyway`);
374
+ } else {
375
+ log?.note(outcome.kind === "ready" ? `replacement pid ${pid} is serving ${target}` : `replacement pid ${pid} started`);
376
+ }
377
+ return;
378
+ }
379
+ } finally {
380
+ log?.close();
381
+ }
382
+ }
@@ -38,6 +38,14 @@ export function startupHealthProbeTimeoutMs(): number {
38
38
  return PROBE_TIMEOUT_MS;
39
39
  }
40
40
  let cached: { timestamp: number; value: StartupHealth } | null = null;
41
+ /**
42
+ * Last completed reading, kept across invalidation. Invalidation only means "no longer
43
+ * fresh": a settings write clears `cached` so the next fresh read re-probes, but answering
44
+ * the snapshot with the synthetic not-installed fallback meanwhile turned a healthy service
45
+ * into `at-risk` on the dashboard until the probe finished (up to 15s on Windows). The
46
+ * fallback is kept for a process that has never completed a reading.
47
+ */
48
+ let lastReading: StartupHealth | null = null;
41
49
  let inflight: Promise<StartupHealth> | null = null;
42
50
  let generation = 0;
43
51
 
@@ -64,7 +72,11 @@ export function getStartupHealthSnapshot(
64
72
  const now = deps.now ?? Date.now;
65
73
  if (cached && now() - cached.timestamp < CACHE_TTL_MS) return cached.value;
66
74
  refreshInBackground(config, deps);
67
- return cached ? markStartupHealthDiagnosticStale(cached.value) : conservativeFallback(config);
75
+ return staleOrFallback(config);
76
+ }
77
+
78
+ function staleOrFallback(config: Pick<OcxConfig, "codexAutoStart">): StartupHealth {
79
+ return lastReading ? markStartupHealthDiagnosticStale(lastReading) : conservativeFallback(config);
68
80
  }
69
81
 
70
82
  export function markStartupHealthDiagnosticStale(value: StartupHealth): StartupHealth {
@@ -140,7 +152,7 @@ function runProbe(config: Pick<OcxConfig, "codexAutoStart">): Promise<StartupHea
140
152
  } catch { /* scan earlier output; config repair messages may precede JSON */ }
141
153
  }
142
154
  }
143
- resolve(cached ? markStartupHealthDiagnosticStale(cached.value) : conservativeFallback(config));
155
+ resolve(staleOrFallback(config));
144
156
  });
145
157
  });
146
158
  }
@@ -156,10 +168,11 @@ function refreshInBackground(
156
168
  .then(value => {
157
169
  if (startedGeneration === generation) {
158
170
  cached = { timestamp: (deps.now ?? Date.now)(), value };
171
+ if (!value.diagnosticStale) lastReading = value;
159
172
  }
160
173
  return value;
161
174
  })
162
- .catch(() => cached ? markStartupHealthDiagnosticStale(cached.value) : conservativeFallback(config))
175
+ .catch(() => staleOrFallback(config))
163
176
  .finally(() => {
164
177
  // An invalidated probe must never clear the newer generation's flight.
165
178
  if (inflight === probe) inflight = null;
@@ -189,7 +202,7 @@ export async function getCachedStartupHealth(
189
202
  ]));
190
203
  if (settled) return settled;
191
204
  }
192
- return cached ? markStartupHealthDiagnosticStale(cached.value) : conservativeFallback(config);
205
+ return staleOrFallback(config);
193
206
  }
194
207
 
195
208
  export function invalidateStartupHealthCache(): void {
@@ -197,3 +210,9 @@ export function invalidateStartupHealthCache(): void {
197
210
  cached = null;
198
211
  inflight = null;
199
212
  }
213
+
214
+ /** Test-only: also forget the last reading, returning to the never-probed state. */
215
+ export function resetStartupHealthCacheForTests(): void {
216
+ invalidateStartupHealthCache();
217
+ lastReading = null;
218
+ }
@@ -1,5 +1,8 @@
1
1
  import type { CodexNativeRestoreResult } from "../codex/inject";
2
+ import { siblingOfLivePort, siblingSkipMessage } from "../codex/sibling-start";
2
3
  import { deferralMatchesReceipt } from "../config/pending-teardown";
4
+ import { isDesktopSupervised } from "../lib/system-restart-contract";
5
+ import type { ManagementPrincipal } from "./management-auth";
3
6
 
4
7
  /**
5
8
  * Shared-teardown decision and execution for `POST /api/stop` (#3008).
@@ -22,7 +25,8 @@ export type StopTeardownIo = {
22
25
  export type StopTeardownBody = {
23
26
  success: boolean;
24
27
  message: string;
25
- sharedTeardown: "deferred" | "performed";
28
+ /** `not-owned`: a sibling instance, whose shared client routing belongs to the live owner. */
29
+ sharedTeardown: "deferred" | "performed" | "not-owned";
26
30
  };
27
31
 
28
32
  /**
@@ -43,8 +47,38 @@ export function deferralHonored(url: URL, ownsReceipt: (nonce: string | null) =>
43
47
  return ownsReceipt(url.searchParams.get("teardownNonce"));
44
48
  }
45
49
 
50
+ export type StopRefusalBody = { success: false; code: string; message: string };
51
+
52
+ /**
53
+ * The dashboard's Stop, refused while the desktop app supervises this process.
54
+ *
55
+ * The app starts its runtime again after any exit it did not ask for
56
+ * (`desktop/src-tauri/src/supervisor.rs`), and a dashboard Stop is not the app asking: the proxy
57
+ * would restore native Codex and exit, and the app would start it again seconds later and reload
58
+ * the dashboard. Like the service-manager refusals it is refused before anything changes, and it
59
+ * names what does stop the proxy: the app's tray Stop proxy, or Quit. Only a dashboard session is
60
+ * refused. `ocx stop`, which the tray's Stop, Quit and an update's drain all run, authenticates
61
+ * with the admin token and is unaffected. Read only on this route, never on the request path.
62
+ */
63
+ export function desktopSupervisedStopRefusal(
64
+ principal: ManagementPrincipal | null | undefined,
65
+ supervised: () => boolean = isDesktopSupervised,
66
+ ): StopRefusalBody | null {
67
+ if (principal !== "gui-session" || !supervised()) return null;
68
+ return {
69
+ success: false,
70
+ code: "desktop_supervised",
71
+ message: "The OpenCodex desktop app runs this proxy and starts it again after it exits, so the dashboard does not stop it. Use Stop proxy in the app's tray menu, or quit the app. Nothing was changed.",
72
+ };
73
+ }
74
+
46
75
  /** Run (or skip) the shared teardown and describe the outcome truthfully. */
47
76
  export async function performStopTeardown(url: URL, io: StopTeardownIo = {}): Promise<StopTeardownBody> {
77
+ // Before the deferral check: a sibling owns no shared teardown to perform OR to hand over.
78
+ // Restoring here would replay the live owner's journal and strip its Grok fence.
79
+ if (siblingOfLivePort() !== null) {
80
+ return { success: true, message: `Proxy stopping. ${siblingSkipMessage()}`, sharedTeardown: "not-owned" };
81
+ }
48
82
  const ownsReceipt = io.ownsReceipt ?? deferralMatchesReceipt;
49
83
  if (deferralHonored(url, ownsReceipt)) {
50
84
  // Not "native Codex restored": nothing was restored here, and claiming otherwise
@@ -11,6 +11,7 @@ import { localAdmissionToken, localInferenceDestination } from "../lib/local-des
11
11
  import { probeHostname } from "./proxy-liveness";
12
12
  import { providerContextCap } from "../providers/context-cap";
13
13
  import { OPENAI_CODEX_PROVIDER_ID } from "../providers/openai-tiers";
14
+ import { siblingOfLivePort } from "../codex/sibling-start";
14
15
  export { getShellEnvFilePath, installShellHook, uninstallShellHook, claudeCodeCliInstalled, reconcileShellHook } from "./system-env-shell";
15
16
  export type { SystemEnvDeps } from "./system-env-shell";
16
17
  import { systemEnvMarkerMode, writeShellEnvFile, removeShellEnvFile } from "./system-env-shell";
@@ -196,6 +197,8 @@ export async function injectSystemEnv(
196
197
  config: OcxConfig,
197
198
  deps: SystemEnvDeps = {},
198
199
  ): Promise<SystemEnvResult> {
200
+ // The launchd domain is machine-wide; a sibling instance leaves it to the live owner.
201
+ if (siblingOfLivePort() !== null) return { injected: false, reason: "sibling instance" };
199
202
  if (process.platform !== "darwin") return { injected: false, reason: "not macOS" };
200
203
  if (config.claudeCode?.enabled === false) return { injected: false, reason: "claude disabled" };
201
204
 
@@ -1,3 +1,4 @@
1
+ import { WINDOWS_WRAPPER_PROTOCOL_ENV, WINDOWS_WRAPPER_STAY_OUT_EXIT_CODE } from "./windows-wrapper-exit";
1
2
  import { readFileSync } from "node:fs";
2
3
  import { TASK, windowsServiceScriptPath, windowsLauncherVbsPath, windowsTaskXmlPath } from "./state";
3
4
  import { windowsWscript } from "./windows-scheduler";
@@ -64,11 +65,13 @@ export function buildWindowsServiceScript(
64
65
  const path = process.env.PATH ?? "";
65
66
  const lines = [
66
67
  "@echo off",
67
- "setlocal",
68
+ "setlocal EnableExtensions DisableDelayedExpansion",
69
+ 'set "ERRORLEVEL="',
68
70
  // The wrapper console is hidden by the wscript launcher (window style 0), so switching
69
71
  // it to UTF-8 is safe (no leak into user shells) and lets cmd parse UTF-8 remnants.
70
72
  "chcp 65001 >nul",
71
73
  windowsBatchSet("OCX_SERVICE", "1"),
74
+ windowsBatchSet(WINDOWS_WRAPPER_PROTOCOL_ENV, "1"),
72
75
  windowsBatchSet(BUN_RUNTIME_SOURCE_ENV, bunRuntimeSource),
73
76
  windowsBatchSet(BUN_RUNTIME_PATH_ENV, bun, "path"),
74
77
  windowsBatchSet("PATH", path, "pathList"),
@@ -110,15 +113,16 @@ export function buildWindowsServiceScript(
110
113
  cli ? " exit /b 3" : null,
111
114
  cli ? ")" : null,
112
115
  cli ? `"%OCX_BUN%" "%OCX_CLI%" start --port ${port} >>"%OCX_SERVICE_LOG%" 2>&1` : `"%OCX_BUN%" start --port ${port} >>"%OCX_SERVICE_LOG%" 2>&1`,
113
- "if %ERRORLEVEL% NEQ 0 (",
114
- ' >>"%OCX_SERVICE_LOG%" echo [%DATE% %TIME%] child exited with code %ERRORLEVEL%; restarting in 5s',
116
+ // Stop commands kill the wrapper; a zero child exit alone is not a stop request.
117
+ `if "%ERRORLEVEL%"=="${WINDOWS_WRAPPER_STAY_OUT_EXIT_CODE}" goto stopped`,
118
+ '>>"%OCX_SERVICE_LOG%" echo [%DATE% %TIME%] child exited with code %ERRORLEVEL%; restarting in 5s',
115
119
  // `timeout` needs console stdin and dies with "Input redirection is not supported"
116
120
  // under Task Scheduler, turning the 5s cooldown into a hot restart loop; ping doesn't.
117
- " ping -n 6 127.0.0.1 >nul",
118
- " goto loop",
119
- ")",
121
+ "ping -n 6 127.0.0.1 >nul",
122
+ "goto loop",
123
+ ":stopped",
120
124
  "endlocal",
121
- "goto :eof",
125
+ "exit /b 0",
122
126
  "",
123
127
  // #1942/#1849: a power loss mid-swap leaves the live package dir missing/broken and
124
128
  // a sibling .ocx-backup-* holding the previous version. This wrapper lives OUTSIDE
@@ -0,0 +1,8 @@
1
+ // Only wrappers advertising this protocol understand a nonzero intentional exit.
2
+ export const WINDOWS_WRAPPER_PROTOCOL_ENV = "OCX_WINDOWS_WRAPPER_PROTOCOL";
3
+ export const WINDOWS_WRAPPER_STAY_OUT_EXIT_CODE = 42;
4
+
5
+ export function serviceStayOutExitCode(env: NodeJS.ProcessEnv = process.env): number {
6
+ return env.OCX_SERVICE === "1" && env[WINDOWS_WRAPPER_PROTOCOL_ENV] === "1"
7
+ ? WINDOWS_WRAPPER_STAY_OUT_EXIT_CODE : 0;
8
+ }
@@ -1,20 +1,45 @@
1
+ export const DEFAULT_STALL_TIMEOUT_SEC = 300;
2
+
1
3
  /**
2
- * Bridge upstream stall budget: seconds of silence (no adapter events) before the
3
- * Responses bridge emits `response.incomplete` / `upstream_stall_timeout`.
4
+ * How the configured stall budget should be resolved for a request.
4
5
  *
5
- * Raised from 90s so long reasoning + large tool writes are not cut mid-turn.
6
- * Hung streams still die; they just get a more realistic window.
6
+ * `localUpstream` marks a request whose upstream is operator-trusted local infrastructure
7
+ * (loopback / private / `.local` / `.lan` — see src/lib/local-upstream.ts). Such models are often
8
+ * CPU-bound, and their time-to-first-token or "thinking" phases can go silent for many minutes;
9
+ * a stall budget there would cut a healthy turn mid-thought. Callers compute the flag with
10
+ * `isLocalUpstream(destination)` and pass it in.
7
11
  */
8
- export const DEFAULT_STALL_TIMEOUT_SEC = 300;
12
+ export interface StallTimeoutOptions {
13
+ localUpstream?: boolean;
14
+ }
9
15
 
10
16
  /**
11
- * Resolve the effective bridge stall deadline for a turn.
12
- * - unset / non-finite config → {@link DEFAULT_STALL_TIMEOUT_SEC}
13
- * - finite config → ceil, minimum 1
17
+ * Resolve the per-request stall budget in seconds.
18
+ *
19
+ * An explicit value always wins: `0` (or any non-positive) disables the budget everywhere, and a
20
+ * positive value applies verbatim to local and public upstreams alike. Only when the operator
21
+ * left the budget unset does the destination matter: a local upstream resolves to disabled
22
+ * (`0`) while a public upstream keeps the {@link DEFAULT_STALL_TIMEOUT_SEC} safety clock.
23
+ *
24
+ * Disabling is expressed as `0`, never as a tiny budget: several consumers translate the
25
+ * seconds into a tick count or a pull deadline, where `0` would otherwise expire on the first
26
+ * beat and kill a healthy stream in ~2s. Consumers that kill on the budget must therefore gate
27
+ * on `resolveStallTimeoutSec(...) > 0` (see src/bridge/sse.ts, src/server/chat-native-sse.ts).
14
28
  */
15
- export function resolveStallTimeoutSec(configuredSec: number | undefined): number {
29
+ export function resolveStallTimeoutSec(configuredSec: number | undefined, options: StallTimeoutOptions = {}): number {
30
+ const { localUpstream = false } = options;
16
31
  if (typeof configuredSec === "number" && Number.isFinite(configuredSec)) {
32
+ if (configuredSec <= 0) return 0;
17
33
  return Math.max(1, Math.ceil(configuredSec));
18
34
  }
19
- return DEFAULT_STALL_TIMEOUT_SEC;
35
+ return localUpstream ? 0 : DEFAULT_STALL_TIMEOUT_SEC;
36
+ }
37
+
38
+ /**
39
+ * The same resolution as {@link resolveStallTimeoutSec}, in milliseconds, for consumers that
40
+ * deadline against `performance.now()`. `0` is a stable "disabled" sentinel the inactivity
41
+ * guards honor (they arm no clock for a non-positive budget).
42
+ */
43
+ export function resolveStallTimeoutMs(configuredSec: number | undefined, options: StallTimeoutOptions = {}): number {
44
+ return resolveStallTimeoutSec(configuredSec, options) * 1_000;
20
45
  }
@@ -8,6 +8,7 @@
8
8
  import type { CleanupMode, CleanupResult } from "./cleanup";
9
9
  import { spawnWorker } from "../lib/worker-embed";
10
10
  import { resolveCodexHomeDir } from "../codex/home";
11
+ import { siblingOfLivePort } from "../codex/sibling-start";
11
12
  import {
12
13
  tryBeginStorageMutation,
13
14
  } from "./storage-mutation-coordinator";
@@ -452,11 +453,14 @@ export function requestStorageCleanupPolicyRun(
452
453
  /**
453
454
  * Fire-and-forget entry for startup / schedule ticks.
454
455
  * Skips the single-flight slot when disabled or not due so manual runs stay free.
456
+ * A sibling instance never runs one: the archived sessions belong to the shared CODEX_HOME the
457
+ * live owner serves (`src/codex/sibling-start.ts`), and the manual run route is refused there too.
455
458
  */
456
459
  export function maybeRequestStorageCleanupPolicyRun(
457
460
  reason: PolicyRunReason,
458
461
  opts?: Omit<RequestPolicyRunOptions, "reason">,
459
462
  ): void {
463
+ if (siblingOfLivePort() !== null) return;
460
464
  try {
461
465
  const policy = readStorageCleanupPolicyFromConfig();
462
466
  if (!policy.enabled) return;