@bitkyc08/opencodex 2.81.0 → 2.83.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 (272) hide show
  1. package/bin/ocx.mjs +166 -73
  2. package/gui/dist/assets/App-Ba8uG696.js +54 -0
  3. package/gui/dist/assets/{Tray-EwAOsP0S.js → Tray-IImbNPRy.js} +1 -1
  4. package/gui/dist/assets/{index-Brk6F3fn.css → index-BAbcj51J.css} +1 -1
  5. package/gui/dist/assets/{index-RsoaAxXY.js → index-BHRZdJuv.js} +13 -13
  6. package/gui/dist/assets/{quota-summary-D2lN8tDm.js → quota-summary-DiJO6sYY.js} +1 -1
  7. package/gui/dist/index.html +2 -2
  8. package/package.json +1 -1
  9. package/src/adapters/anthropic.ts +1 -1
  10. package/src/adapters/cursor/catalog.ts +2 -2
  11. package/src/adapters/cursor/effort-map.ts +1 -1
  12. package/src/adapters/devin/live-models.ts +2 -2
  13. package/src/adapters/google-truncation.ts +22 -0
  14. package/src/adapters/google.ts +7 -3
  15. package/src/adapters/ollama-native.ts +18 -14
  16. package/src/adapters/openai-responses/compaction-search-history.ts +109 -0
  17. package/src/adapters/openai-responses/passthrough.ts +11 -4
  18. package/src/adapters/openai-responses/plaintext-reasoning-notice.ts +85 -0
  19. package/src/adapters/openai-responses/reasoning.ts +11 -1
  20. package/src/adapters/openai-responses/tool-output-recovery.ts +20 -0
  21. package/src/bridge/errors.ts +7 -1
  22. package/src/claude/agents-inject.ts +14 -3
  23. package/src/claude/context-windows.ts +22 -20
  24. package/src/claude/desktop-3p.ts +88 -12
  25. package/src/claude/desktop-picker.ts +1 -1
  26. package/src/claude/desktop-profile.ts +2 -1
  27. package/src/claude/gateway-cache.ts +18 -1
  28. package/src/claude/inbound.ts +77 -3
  29. package/src/claude/intercept/cli-catalog.ts +3 -0
  30. package/src/claude/intercept/picker-ca-persistence.ts +7 -6
  31. package/src/claude/intercept/picker-models.ts +14 -6
  32. package/src/claude/intercept/settings.ts +3 -0
  33. package/src/claude/long-context.ts +66 -0
  34. package/src/claude/model-info.ts +19 -16
  35. package/src/claude/outbound.ts +94 -9
  36. package/src/claude/subagent-model.ts +40 -11
  37. package/src/cli/account-auth.ts +20 -12
  38. package/src/cli/agent.ts +9 -5
  39. package/src/cli/aside-profile-recovery.ts +33 -0
  40. package/src/cli/capabilities-base.ts +5 -5
  41. package/src/cli/capabilities-integrations.ts +2 -2
  42. package/src/cli/cli-path-diagnostics.ts +132 -0
  43. package/src/cli/desktop-runtime-guidance.ts +65 -0
  44. package/src/cli/dispatch.ts +5 -0
  45. package/src/cli/doctor.ts +16 -3
  46. package/src/cli/index.ts +14 -9
  47. package/src/cli/integration-aside-sync.ts +2 -1
  48. package/src/cli/integrations.ts +16 -3
  49. package/src/cli/resolve.ts +26 -3
  50. package/src/cli/root.ts +4 -2
  51. package/src/cli/status.ts +64 -12
  52. package/src/cli/update-restart-child.ts +4 -2
  53. package/src/cli/update-restart-home.ts +28 -10
  54. package/src/cli/update-restart-service-record.ts +116 -0
  55. package/src/cli/update-restart-supervision.ts +96 -0
  56. package/src/cli/update-restart.ts +59 -13
  57. package/src/cli/v2.ts +27 -20
  58. package/src/cli/version-skew-notice.ts +77 -0
  59. package/src/clients/config-export/model-metadata.ts +16 -0
  60. package/src/clients/config-export/zcode-store.ts +46 -11
  61. package/src/clients/config-export/zcode.ts +3 -5
  62. package/src/clients/config-export.ts +1 -1
  63. package/src/clients/lazycodex.ts +1 -1
  64. package/src/clients/omo-role-models.ts +32 -7
  65. package/src/codex/auth-api/account-selection.ts +107 -0
  66. package/src/codex/auth-api/pool-mode-gate.ts +4 -5
  67. package/src/codex/auth-api/routes.ts +10 -49
  68. package/src/codex/auth-context.ts +28 -4
  69. package/src/codex/autostart-health.ts +12 -3
  70. package/src/codex/catalog/build-entries.ts +18 -0
  71. package/src/codex/catalog/bundled.ts +155 -26
  72. package/src/codex/catalog/combo-member.ts +3 -2
  73. package/src/codex/catalog/effort.ts +26 -5
  74. package/src/codex/catalog/gather-capture.ts +5 -0
  75. package/src/codex/catalog/model-hints.ts +15 -8
  76. package/src/codex/catalog/model-visibility.ts +3 -1
  77. package/src/codex/catalog/native-models.ts +1 -1
  78. package/src/codex/catalog/operator-tiers.ts +38 -0
  79. package/src/codex/catalog/provider-models.ts +68 -35
  80. package/src/codex/catalog/retained-sync.ts +7 -5
  81. package/src/codex/catalog/routed-gather.ts +15 -1
  82. package/src/codex/catalog-auto-refresh-sources.ts +3 -4
  83. package/src/codex/catalog-auto-refresh.ts +19 -0
  84. package/src/codex/catalog.ts +1 -1
  85. package/src/codex/codex-home-owner.ts +3 -0
  86. package/src/codex/config-write-lock.ts +333 -0
  87. package/src/codex/convergence.ts +2 -1
  88. package/src/codex/desktop-app/darwin.ts +1 -1
  89. package/src/codex/desktop-app/handoff.ts +1 -1
  90. package/src/codex/desktop-app/linux.ts +1 -1
  91. package/src/codex/desktop-app/lock.ts +1 -1
  92. package/src/codex/desktop-app/types.ts +1 -1
  93. package/src/codex/desktop-app/windows.ts +1 -1
  94. package/src/codex/desktop-app-restart.ts +1 -1
  95. package/src/codex/features.ts +257 -200
  96. package/src/codex/inject/config-write-section.ts +34 -0
  97. package/src/codex/inject/multi-agent-v2.ts +14 -6
  98. package/src/codex/inject/remove.ts +36 -11
  99. package/src/codex/inject/restore.ts +85 -20
  100. package/src/codex/inject-coordination.ts +15 -6
  101. package/src/codex/inject.ts +62 -20
  102. package/src/codex/journal.ts +62 -17
  103. package/src/codex/main-account-cache.ts +43 -0
  104. package/src/codex/main-account.ts +6 -7
  105. package/src/codex/paths.ts +2 -0
  106. package/src/codex/prepared-home.ts +18 -0
  107. package/src/codex/prompt-journal.ts +25 -12
  108. package/src/codex/prompt-layers.ts +49 -26
  109. package/src/codex/prompt-lock-claim.ts +134 -0
  110. package/src/codex/prompt-lock-evidence.ts +26 -0
  111. package/src/codex/prompt-lock-io.ts +16 -0
  112. package/src/codex/prompt-lock-owner.ts +130 -0
  113. package/src/codex/prompt-lock.ts +84 -53
  114. package/src/codex/routing/failure-window.ts +146 -0
  115. package/src/codex/routing/selection.ts +4 -1
  116. package/src/codex/routing.ts +13 -17
  117. package/src/codex/runtime.ts +106 -18
  118. package/src/codex/shim.ts +45 -19
  119. package/src/combos/jev-service-exchange.ts +281 -0
  120. package/src/combos/jev.ts +34 -220
  121. package/src/config/atomic-write.ts +1 -1
  122. package/src/config/codex-ws-reuse-setting.ts +10 -0
  123. package/src/config/live-reconcile.ts +17 -0
  124. package/src/config/load-degrade.ts +8 -0
  125. package/src/config/rebase-provenance.ts +11 -0
  126. package/src/config/schema/config-schema.ts +4 -0
  127. package/src/generated/compatibility-version.json +375 -227
  128. package/src/generated/model-metadata.ts +2 -2
  129. package/src/grok/inject.ts +276 -206
  130. package/src/grok/reset-coupon-ledger.ts +223 -57
  131. package/src/grok/reset-coupons.ts +13 -6
  132. package/src/grok/sync.ts +2 -1
  133. package/src/images/loop.ts +29 -11
  134. package/src/integrations/aside-profiles.ts +10 -3
  135. package/src/integrations/catalog-refresh.ts +18 -2
  136. package/src/integrations/cursor-detect.ts +1 -1
  137. package/src/integrations/writer.ts +9 -3
  138. package/src/lib/bun-path-runtime.d.mts +32 -0
  139. package/src/lib/bun-path-runtime.mjs +95 -0
  140. package/src/lib/bun-runtime-preflight.ts +140 -0
  141. package/src/lib/desktop-cli-handoff.d.mts +20 -0
  142. package/src/lib/desktop-cli-handoff.mjs +92 -0
  143. package/src/lib/desktop-cli-record.d.mts +29 -0
  144. package/src/lib/desktop-cli-record.mjs +102 -0
  145. package/src/lib/errors.ts +25 -0
  146. package/src/lib/redact.ts +2 -2
  147. package/src/lib/request-execution-budget.ts +8 -0
  148. package/src/lib/synced-state-location.ts +46 -6
  149. package/src/lib/test-home-guard.ts +87 -1
  150. package/src/lib/translator-budget.ts +17 -4
  151. package/src/lib/windows-atomic-replace.ts +38 -16
  152. package/src/lib/windows-elevation.ts +6 -0
  153. package/src/lib/windows-secret-acl.ts +92 -25
  154. package/src/lib/winsw.ts +2 -3
  155. package/src/oauth/anthropic-account-refusal.ts +39 -1
  156. package/src/oauth/anthropic-routing.ts +1 -1
  157. package/src/oauth/anthropic-send-ownership.ts +3 -3
  158. package/src/oauth/chatgpt.ts +113 -41
  159. package/src/oauth/index.ts +7 -3
  160. package/src/oauth/meta-muse-device.ts +1 -1
  161. package/src/oauth/store.ts +1 -1
  162. package/src/oauth/types.ts +1 -1
  163. package/src/providers/anthropic-fast.ts +1 -1
  164. package/src/providers/anthropic-reset-grant-ledger.ts +1 -1
  165. package/src/providers/azure-model-metadata.ts +115 -0
  166. package/src/providers/derive.ts +21 -0
  167. package/src/providers/devin-cli-authmode-migration.ts +1 -1
  168. package/src/providers/devin-provider-merge-migration.ts +1 -1
  169. package/src/providers/github-copilot-auto.ts +169 -0
  170. package/src/providers/model-presets.ts +1 -1
  171. package/src/providers/muse-subscription-usage.ts +1 -1
  172. package/src/providers/quota/vendor-probes-key.ts +127 -17
  173. package/src/providers/quota.ts +1 -1
  174. package/src/providers/registry/entries-core.ts +6 -6
  175. package/src/providers/registry/entries-extended.ts +4 -0
  176. package/src/providers/registry/model-seeds.ts +3 -3
  177. package/src/providers/xai-fast-model.ts +1 -1
  178. package/src/responses/reasoning-envelope.ts +29 -1
  179. package/src/responses/reasoning-replay-cache.ts +17 -1
  180. package/src/responses/spill-store.ts +15 -2
  181. package/src/responses/state/metrics.ts +8 -0
  182. package/src/responses/state/spill-failure.ts +12 -0
  183. package/src/responses/state/spill-queue.ts +12 -5
  184. package/src/responses/state.ts +18 -10
  185. package/src/responses/task-input.ts +17 -0
  186. package/src/server/background-lifecycle.ts +27 -0
  187. package/src/server/chat-completions.ts +1 -0
  188. package/src/server/chat-native.ts +28 -12
  189. package/src/server/claude-messages.ts +43 -6
  190. package/src/server/inbound-body-admission.ts +25 -7
  191. package/src/server/index/claude-intercept-lifecycle.ts +3 -1
  192. package/src/server/index/link-listener.ts +30 -17
  193. package/src/server/index/serve-options.ts +18 -4
  194. package/src/server/index/spend-ledger-lifecycle.ts +2 -1
  195. package/src/server/index/startup-warnings.ts +19 -0
  196. package/src/server/index.ts +2 -1
  197. package/src/server/lifecycle.ts +31 -4
  198. package/src/server/management/agent-settings-routes.ts +93 -32
  199. package/src/server/management/codex-agent-role-routes.ts +4 -2
  200. package/src/server/management/codex-prompt-routes.ts +1 -0
  201. package/src/server/management/codex-role-auto-assign.ts +13 -2
  202. package/src/server/management/config-routes.ts +59 -8
  203. package/src/server/management/context.ts +2 -2
  204. package/src/server/management/grok-coupon-routes.ts +277 -52
  205. package/src/server/management/link-routes.ts +42 -20
  206. package/src/server/management/route-registry.ts +2 -2
  207. package/src/server/management/shared.ts +3 -3
  208. package/src/server/messages-native-oauth.ts +9 -8
  209. package/src/server/messages-native.ts +49 -13
  210. package/src/server/messages-response-headers.ts +14 -0
  211. package/src/server/relay-eager.ts +2 -0
  212. package/src/server/relay.ts +18 -7
  213. package/src/server/request-log-conversation.ts +48 -5
  214. package/src/server/request-log-failure-attribution.ts +3 -0
  215. package/src/server/request-log-terminal-status.ts +186 -0
  216. package/src/server/request-log.ts +15 -89
  217. package/src/server/responses/adapter-continuation.ts +11 -3
  218. package/src/server/responses/adapter-dispatch.ts +99 -19
  219. package/src/server/responses/codex-ws-exchange.ts +17 -2
  220. package/src/server/responses/codex-ws-pool.ts +100 -13
  221. package/src/server/responses/codex-ws-wire.ts +9 -0
  222. package/src/server/responses/combo-requested-effort.ts +3 -1
  223. package/src/server/responses/compact.ts +3 -2
  224. package/src/server/responses/core-codex-account.ts +22 -1
  225. package/src/server/responses/core-combo-native.ts +20 -3
  226. package/src/server/responses/core-combo.ts +22 -14
  227. package/src/server/responses/core-lifetime.ts +3 -36
  228. package/src/server/responses/core-opaque-recovery.ts +5 -5
  229. package/src/server/responses/core-options.ts +5 -0
  230. package/src/server/responses/core-replay.ts +73 -0
  231. package/src/server/responses/core.ts +9 -9
  232. package/src/server/responses/empty-completion-guard.ts +18 -2
  233. package/src/server/responses/fetch-helpers.ts +26 -3
  234. package/src/server/responses/jev-model-invoke.ts +1 -1
  235. package/src/server/responses/passthrough-delivery.ts +24 -2
  236. package/src/server/responses/passthrough-dispatch.ts +134 -62
  237. package/src/server/responses/passthrough-execution.ts +3 -3
  238. package/src/server/responses/request-prepare.ts +4 -0
  239. package/src/server/responses/request-send-budget.ts +112 -49
  240. package/src/server/responses/request-transport.ts +125 -5
  241. package/src/server/responses/response-effects.ts +1 -0
  242. package/src/server/responses/run-turn-execution.ts +15 -5
  243. package/src/server/responses/sidecar-execution.ts +30 -16
  244. package/src/server/responses/sidecar-send-budget.ts +58 -0
  245. package/src/server/responses/spend-storage-error.ts +33 -0
  246. package/src/server/responses/ws-upstream.ts +19 -6
  247. package/src/server/startup-health-cache.ts +2 -1
  248. package/src/server/workflow-refusal.ts +3 -0
  249. package/src/service/cli.ts +20 -4
  250. package/src/service/desktop-command-guard.ts +32 -0
  251. package/src/service/desktop-startup.ts +66 -40
  252. package/src/service/desktop-supervision.d.mts +26 -0
  253. package/src/service/desktop-supervision.mjs +140 -0
  254. package/src/service/managing-cli.ts +1 -0
  255. package/src/service/orchestration.ts +46 -20
  256. package/src/service/repair.ts +25 -9
  257. package/src/service/state.ts +4 -3
  258. package/src/service/systemd.ts +17 -8
  259. package/src/service/windows-ops.ts +61 -22
  260. package/src/storage/policy-job.ts +5 -1
  261. package/src/storage/policy-worker.ts +3 -1
  262. package/src/storage/policy.ts +9 -0
  263. package/src/types/config.ts +21 -0
  264. package/src/types/request.ts +10 -0
  265. package/src/types/wire.ts +1 -0
  266. package/src/update/index.ts +57 -9
  267. package/src/update/restart-ownership.ts +10 -3
  268. package/src/update/runtime-ownership.d.mts +6 -0
  269. package/src/update/runtime-ownership.mjs +23 -7
  270. package/src/usage/expected-prices.ts +1 -1
  271. package/src/web-search/loop.ts +5 -2
  272. package/gui/dist/assets/App-7yhoys8a.js +0 -54
@@ -13,13 +13,14 @@ import {
13
13
  type RuntimeApiDeps,
14
14
  } from "./runtime-api";
15
15
  import { clientIntegrationPath, validateAsideProfile } from "./integration-input";
16
+ import { asideProfileRecoveryLines } from "./aside-profile-recovery";
16
17
  import type { IntegrationClientId } from "../integrations/registry";
17
18
 
18
19
  const CLAUDE_USAGE = `Usage:
19
20
  ocx claude config [status] [--json]
20
21
  ocx claude config set [--enabled <on|off>] [--auth-mode <auto|proxy|subscription>]
21
22
  [--system-env <on|off>] [--fast-mode <on|off>] [--auto-context <on|off>]
22
- [--compact-window <tokens|default>] [--inject-agents <on|off>]
23
+ [--compact-window <tokens|default>] [--context-accounting <1m|200k>] [--inject-agents <on|off>]
23
24
  [--small-fast-model <id|->] [--model-map <from=to,from=to|->]
24
25
  [--blocked-skills <name,name|->] [--web-model <id|->] [--web-backend <openai|anthropic|xai|gemini|exa|->]
25
26
  [--vision-model <id|->] [--vision-backend <openai|anthropic|->] [--json]
@@ -75,6 +76,7 @@ export async function handleClaudeConfigCommand(argv: string[], deps: RuntimeApi
75
76
  const fastMode = takeBooleanOption(args, "--fast-mode");
76
77
  const autoContext = takeBooleanOption(args, "--auto-context");
77
78
  const compact = takeOption(args, "--compact-window");
79
+ const accounting = takeOption(args, "--context-accounting");
78
80
  const injectAgents = takeBooleanOption(args, "--inject-agents");
79
81
  const smallFastModel = takeOption(args, "--small-fast-model");
80
82
  const modelMap = takeOption(args, "--model-map");
@@ -97,6 +99,11 @@ export async function handleClaudeConfigCommand(argv: string[], deps: RuntimeApi
97
99
  body.autoCompactWindow = value;
98
100
  }
99
101
  }
102
+ if (accounting !== undefined) {
103
+ const value = accounting.toLowerCase();
104
+ if (value !== "1m" && value !== "200k") throw new CliUsageError("--context-accounting must be 1m or 200k", CLAUDE_USAGE);
105
+ body.contextAccounting = value;
106
+ }
100
107
  if (injectAgents !== undefined) body.injectAgents = injectAgents;
101
108
  if (smallFastModel !== undefined) body.smallFastModel = smallFastModel === "-" ? "" : smallFastModel;
102
109
  if (modelMap !== undefined) body.modelMap = parseMap(modelMap);
@@ -194,7 +201,10 @@ function raycastBlock(result: unknown): RaycastStatusBlock | null {
194
201
  */
195
202
  function singleClientStatusLines(result: unknown): string[] {
196
203
  const raycast = raycastBlock(result);
197
- if (!raycast) return summaryLines(result);
204
+ if (!raycast) {
205
+ const aside = (result as { clientId?: unknown } | null)?.clientId === "aside";
206
+ return [...summaryLines(result), ...(aside ? asideProfileRecoveryLines([result]) : [])];
207
+ }
198
208
  const rest = Object.fromEntries(Object.entries(result as Record<string, unknown>).filter(([key]) => key !== "raycast"));
199
209
  const lines = [...summaryLines(rest), `plan: ${raycast.plan}`];
200
210
  if (!raycast.aiDirPresent) {
@@ -252,7 +262,10 @@ export async function handleClientIntegrationCommand(
252
262
  const profiles = (result as { profiles?: Array<Record<string, unknown>> }).profiles;
253
263
  printData(result, wantsJson, profiles
254
264
  ? profiles.length > 0
255
- ? profiles.map(row => `${String(row.profileId)} ${String(row.name ?? "Aside")}: ${row.enabled ? "on" : "off"} (${String(row.state)})${row.current ? " [current]" : ""}`)
265
+ ? [
266
+ ...profiles.map(row => `${String(row.profileId)} ${String(row.name ?? "Aside")}: ${row.enabled ? "on" : "off"} (${String(row.state)})${row.current ? " [current]" : ""}`),
267
+ ...asideProfileRecoveryLines(profiles),
268
+ ]
256
269
  : [String((result as { error?: string }).error ?? "No Aside profiles found.")]
257
270
  : rows
258
271
  /*
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * `ocx resolve` — the machine-readable runtime resolution surface for an embedding shell.
3
3
  *
4
- * D5 of devlog/_plan/260921_app_runtime_ownership/: the desktop shell must stop resolving
4
+ * D5 of devlog/_fin/260921_app_runtime_ownership/: the desktop shell must stop resolving
5
5
  * the config home, the port and liveness itself. The tuned probe budgets in
6
6
  * src/server/proxy-liveness.ts exist because a shell-side reimplementation answered
7
7
  * "nobody listening" twice and started duplicate proxies; this verb exposes that module's
@@ -59,6 +59,7 @@ import {
59
59
  } from "../service/ownership-compatibility";
60
60
  import { observeManagingClis } from "../service/managing-cli";
61
61
  import { SERVICE_OWNERSHIP_MINIMUM_CLI_VERSION } from "../service/install-state-contract.mjs";
62
+ import { inspectDesktopSupervision } from "../service/desktop-supervision.mjs";
62
63
  import { computeVersionSkew, type VersionSkew } from "./version-skew";
63
64
 
64
65
  /** Wire version of the resolve document. Bump only on an incompatible shape change. */
@@ -123,6 +124,12 @@ export interface ResolveJson {
123
124
  * takeover. Additive within ocx-resolve/1; older shells ignore it and read as unknown.
124
125
  */
125
126
  versionSkew?: VersionSkew;
127
+ supervisor?: {
128
+ kind: "desktop" | "none" | "unknown" | "unsupported";
129
+ supervisorPid?: number;
130
+ runtimePid?: number;
131
+ app?: string;
132
+ };
126
133
  }
127
134
 
128
135
  export type ResolveTakeover =
@@ -160,6 +167,7 @@ export interface ResolveIo {
160
167
  observeManagers?: (
161
168
  state: ServiceInstallState | null,
162
169
  ) => Readonly<Record<ManagingCliRole, ManagingCliObservation>>;
170
+ inspectSupervision?: typeof inspectDesktopSupervision;
163
171
  stdout?: { log: (s: string) => void };
164
172
  stderr?: { error: (s: string) => void };
165
173
  }
@@ -187,6 +195,7 @@ export function buildResolveJson(
187
195
  cliVersion: string,
188
196
  ownership: ServiceOwnershipResolution,
189
197
  takeover: ResolveTakeover,
198
+ supervision?: ReturnType<typeof inspectDesktopSupervision>,
190
199
  ): ResolveJson {
191
200
  const configured = config.port ?? RESOLVE_DEFAULT_PORT;
192
201
  return {
@@ -199,6 +208,9 @@ export function buildResolveJson(
199
208
  source: live ? live.source : "config",
200
209
  },
201
210
  liveness: livenessJson(live),
211
+ ...(live && supervision ? { supervisor: supervision.kind === "desktop"
212
+ ? { kind: supervision.kind, supervisorPid: supervision.supervisorPid, runtimePid: supervision.runtimePid, app: supervision.app }
213
+ : { kind: supervision.kind } } : {}),
202
214
  ownership,
203
215
  takeover,
204
216
  // A proven absence has nothing to skew against; the field is omitted there rather
@@ -216,7 +228,8 @@ function reportHuman(json: ResolveJson, stdout: { log: (s: string) => void }): v
216
228
  if (live.status === "live") {
217
229
  const pidText = live.pid === null ? "unknown" : String(live.pid);
218
230
  const versionText = live.version ?? "unknown version";
219
- stdout.log(`Proxy live on port ${json.port.effective} (PID ${pidText}, ${versionText}); effective port ${json.port.effective}.`);
231
+ const supervisor = json.supervisor?.kind === "desktop" ? `; supervisor: desktop (pid ${json.supervisor.supervisorPid})` : "";
232
+ stdout.log(`Proxy live on port ${json.port.effective} (PID ${pidText}, ${versionText}); effective port ${json.port.effective}${supervisor}.`);
220
233
  } else {
221
234
  stdout.log(`No live proxy (absence proven); effective port ${json.port.effective} (configured).`);
222
235
  }
@@ -353,7 +366,17 @@ export async function runResolve(args: ResolveArgs, io: ResolveIo = {}): Promise
353
366
  };
354
367
  }
355
368
  }
356
- const json = buildResolveJson(diagnostics.config, live, configHome, cliVersion(), ownership, takeover);
369
+ let supervision: ReturnType<typeof inspectDesktopSupervision> | undefined;
370
+ if (live) {
371
+ try {
372
+ supervision = live.pid === null ? { kind: "unknown", reason: "pid-unavailable", desktopSeen: false }
373
+ : (io.inspectSupervision ?? inspectDesktopSupervision)({ targetPid: live.pid });
374
+ } catch {
375
+ // Supervision is independent evidence; its failure cannot invalidate established liveness.
376
+ supervision = { kind: "unknown", reason: "probe-failed", desktopSeen: false };
377
+ }
378
+ }
379
+ const json = buildResolveJson(diagnostics.config, live, configHome, cliVersion(), ownership, takeover, supervision);
357
380
  if (args.json) stdout.log(JSON.stringify(json));
358
381
  else reportHuman(json, stdout);
359
382
  return 0;
package/src/cli/root.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
- * CLI head: version/help early exits, `ocx ready` pre-parse, and the bounded
3
- * Codex-shim auto-restore preflight, in that order (Phase 1 of the CLI
2
+ * CLI head: version/help early exits, ready/resolve pre-parse, lifecycle version
3
+ * notice, then bounded Codex-shim auto-restore preflight (Phase 1 of the CLI
4
4
  * deepening — moved out of src/cli/index.ts).
5
5
  *
6
6
  * `parseCliHead` is pure (no I/O, no process access) so the ordering and the
@@ -16,6 +16,7 @@ import { maybeAutoRestoreCodexShim } from "./codex-shim-autorestore";
16
16
  import { findCommand } from "./registry";
17
17
  import { printUnknownCommand } from "./help-recovery";
18
18
  import { noteCredentialArgv, redactSecretArgs } from "./secret-args";
19
+ import { maybeNoticeVersionSkew } from "./version-skew-notice";
19
20
 
20
21
  export interface CliHead {
21
22
  kind: "version" | "help" | "ready" | "resolve" | "command";
@@ -141,6 +142,7 @@ export async function runCli(argv: string[]): Promise<CliHead> {
141
142
  console.error(uninstallError);
142
143
  process.exit(2);
143
144
  }
145
+ await maybeNoticeVersionSkew(head.command, head.args);
144
146
  maybeAutoRestoreCodexShim(head.command, head.args);
145
147
  return head;
146
148
  }
package/src/cli/status.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { collectCliPathDiagnostics, formatCliStatusHealthLabel, type CliPathDiagnostics } from "./cli-path-diagnostics";
1
2
  import type { CodexMainAccountPolicyHealth } from "../oauth/health";
2
3
  import { durableBunRuntime } from "../lib/bun-runtime";
3
4
  import { existsSync, readFileSync } from "node:fs";
@@ -7,6 +8,7 @@ import { diagnoseCodexBundledPlugins, type CodexPluginsDiagnostic } from "../cod
7
8
  import { findLiveProxy, probeHostname } from "../server/proxy-liveness";
8
9
  import type { OcxConfig } from "../types";
9
10
  import { diagnoseService, serviceLogPath } from "../service";
11
+ import { inspectDesktopSupervision } from "../service/desktop-supervision.mjs";
10
12
  import { collectStartupHealth, startupHealthReadBudgetMs, type StartupHealth } from "../codex/autostart-health";
11
13
  import { getCodexRoutingKind } from "../codex/inject";
12
14
  import { missingOwnedCatalogPath } from "../codex/inject/config-toml";
@@ -215,6 +217,8 @@ export type CliStatusJson = {
215
217
  * ignores the key is unaffected, and `proxyVersion` is null when nothing is live.
216
218
  */
217
219
  versionSkew: VersionSkew;
220
+ cliCommand: CliPathDiagnostics;
221
+ startupSource: "live" | "local" | "local-supervision-override";
218
222
  };
219
223
 
220
224
  export type CliStatusView = {
@@ -259,12 +263,28 @@ export async function fetchLiveStartupHealth(
259
263
  if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null;
260
264
  const row = payload as Record<string, unknown>;
261
265
  if (row.status !== "native" && row.status !== "protected" && row.status !== "at-risk") return null;
262
- if (row.protection !== "service" && row.protection !== "shim" && row.protection !== "none") return null;
266
+ if (row.protection !== "service" && row.protection !== "desktop" && row.protection !== "shim" && row.protection !== "none") return null;
263
267
  if (row.routingKind !== "native" && row.routingKind !== "opencodex-local"
264
268
  && row.routingKind !== "custom-local" && row.routingKind !== "custom-remote" && row.routingKind !== "unknown") return null;
265
269
  if (row.shimCoverage !== "full" && row.shimCoverage !== "cli-only" && row.shimCoverage !== "none") return null;
266
270
  if (typeof row.platform !== "string") return null;
267
271
  if (row.recommendedCommand !== null && typeof row.recommendedCommand !== "string") return null;
272
+ if (row.recommendedAction !== undefined && row.recommendedAction !== null
273
+ && (typeof row.recommendedAction !== "string" || row.recommendedAction.length > 512)) return null;
274
+ if (row.desktop !== undefined) {
275
+ if (!row.desktop || typeof row.desktop !== "object" || Array.isArray(row.desktop)) return null;
276
+ const desktop = row.desktop as Record<string, unknown>;
277
+ for (const key of ["owned", "loginEnabled", "running", "viable"] as const) {
278
+ if (typeof desktop[key] !== "boolean") return null;
279
+ }
280
+ if (desktop.supervisor !== undefined) {
281
+ if (!desktop.supervisor || typeof desktop.supervisor !== "object" || Array.isArray(desktop.supervisor)) return null;
282
+ const supervisor = desktop.supervisor as Record<string, unknown>;
283
+ if (typeof supervisor.supervisorPid !== "number" || !Number.isFinite(supervisor.supervisorPid)
284
+ || typeof supervisor.runtimePid !== "number" || !Number.isFinite(supervisor.runtimePid)
285
+ || typeof supervisor.app !== "string") return null;
286
+ }
287
+ }
268
288
  if (!row.commands || typeof row.commands !== "object" || Array.isArray(row.commands)) return null;
269
289
  for (const key of ["installService", "repairService", "installShim", "restoreNative"] as const) {
270
290
  if (typeof (row.commands as Record<string, unknown>)[key] !== "string") return null;
@@ -286,12 +306,36 @@ export async function fetchLiveStartupHealth(
286
306
  return payload as StartupHealth;
287
307
  }
288
308
 
289
- /** Prefer an attested live verdict and evaluate the local fallback only when live state is absent. */
309
+ /** Older runtimes cannot report supervision; only that missing capability permits a local override. */
290
310
  export function selectStatusStartupHealth(
291
311
  liveStartup: StartupHealth | null,
292
312
  fallback: () => StartupHealth,
293
- ): StartupHealth {
294
- return liveStartup ?? fallback();
313
+ supervision?: () => ReturnType<typeof inspectDesktopSupervision>,
314
+ livePid?: number | null,
315
+ ): { startup: StartupHealth; startupSource: CliStatusJson["startupSource"] } {
316
+ if (!liveStartup) return { startup: fallback(), startupSource: "local" };
317
+ if (liveStartup.desktop === undefined && supervision) {
318
+ let evidence: ReturnType<typeof inspectDesktopSupervision> | undefined;
319
+ try { evidence = supervision(); }
320
+ catch { /* Failed optional evidence cannot replace an attested live verdict. */ }
321
+ if (evidence?.kind === "desktop" && evidence.runtimePid === livePid) {
322
+ const local = fallback();
323
+ const supervisor = local.desktop?.supervisor;
324
+ // The local probe runs later; it must still describe the same unowned process pair.
325
+ if (local.desktop?.owned === false && supervisor
326
+ && supervisor.runtimePid === evidence.runtimePid && supervisor.supervisorPid === evidence.supervisorPid) {
327
+ return { startup: local, startupSource: "local-supervision-override" };
328
+ }
329
+ }
330
+ }
331
+ return { startup: liveStartup, startupSource: "live" };
332
+ }
333
+
334
+ export function runtimeSupervisorLine(startup: StartupHealth): string | null {
335
+ const supervisor = startup.desktop?.supervisor;
336
+ return supervisor
337
+ ? `Runtime supervisor: OpenCodex Desktop (pid ${supervisor.supervisorPid}, ${supervisor.app}); durable owner: ${startup.desktop?.owned ? "desktop" : "none"}`
338
+ : null;
295
339
  }
296
340
 
297
341
  /** Build the service summary from the same startup source that `ocx status` selected. */
@@ -299,18 +343,23 @@ export function statusServiceSummary(
299
343
  liveStartup: StartupHealth | null,
300
344
  service: Pick<ReturnType<typeof diagnoseService>, "installed" | "summary">,
301
345
  live: boolean,
346
+ // Which verdict was selected. A live proxy can still be described by the local reading when
347
+ // the runtime predates supervision reporting, so the label follows the verdict, not liveness.
348
+ startupSource?: CliStatusJson["startupSource"],
302
349
  ): string {
303
- if (liveStartup) {
350
+ if (live && liveStartup) {
351
+ const supervisor = liveStartup.desktop?.supervisor ? "OpenCodex Desktop supervises the running proxy; " : "";
304
352
  if (liveStartup.protection === "service" && liveStartup.serviceViable) {
305
- return `running under the live managed service (logs: ${serviceLogPath()})`;
353
+ return `${supervisor}running under the live managed service (logs: ${serviceLogPath()})`;
306
354
  }
307
355
  const state = [
308
356
  liveStartup.serviceInstalled ? "installed" : "absent",
309
357
  liveStartup.serviceRunning ? "running" : "not running",
310
358
  liveStartup.serviceViable ? "viable" : "not viable",
311
359
  ].join(", ");
312
- const action = liveStartup.recommendedCommand ? `; run '${liveStartup.recommendedCommand}'` : "";
313
- return `live startup reports service ${state}${action} (logs: ${serviceLogPath()})`;
360
+ const action = !liveStartup.desktop?.supervisor && liveStartup.recommendedCommand ? `; run '${liveStartup.recommendedCommand}'` : "";
361
+ const label = (startupSource ?? (live ? "live" : "local")) === "live" ? "live" : "local";
362
+ return `${supervisor}${label} startup reports service ${state}${action} (logs: ${serviceLogPath()})`;
314
363
  }
315
364
  return service.installed && !live
316
365
  ? `${service.summary} — registered but NOT serving; see ${serviceLogPath()} and re-run 'ocx service repair'`
@@ -680,6 +729,7 @@ export function missingCodexCatalogLines(missingCatalogPath: string | null): str
680
729
 
681
730
  /** `mainAccountPolicy`: only `--json` asks; the human report reads the same accounts once via OAuth health. */
682
731
  export async function collectStatus(options: { mainAccountPolicy?: boolean } = {}): Promise<CliStatusView> {
732
+ const cliCommand = collectCliPathDiagnostics();
683
733
  const configDiagnostics = readConfigDiagnostics();
684
734
  const config = configDiagnostics.config;
685
735
  const claudeDesktop = {
@@ -757,14 +807,14 @@ export async function collectStatus(options: { mainAccountPolicy?: boolean } = {
757
807
  // A service can be registered and still not serve: the manager reports the job
758
808
  // either way. When the identity-probed live proxy provides an attested startup verdict,
759
809
  // prefer it over a shell-local service-manager probe that lacks the service environment.
760
- const serviceSummary = statusServiceSummary(liveStartup, service, Boolean(live));
761
810
  const codexShim = diagnoseCodexShim();
762
811
  const codexShimSummary = codexShim.summary;
763
- const startup = selectStatusStartupHealth(liveStartup, () => collectStartupHealth(config, {
812
+ const { startup, startupSource } = selectStatusStartupHealth(liveStartup, () => collectStartupHealth(config, {
764
813
  service,
765
814
  shim: codexShim,
766
815
  routingKind: getCodexRoutingKind(),
767
- }));
816
+ }), live?.pid != null ? () => inspectDesktopSupervision({ targetPid: live.pid! }) : undefined, live?.pid);
817
+ const serviceSummary = statusServiceSummary(startup, service, Boolean(live), startupSource);
768
818
  const codexPlugins = diagnoseCodexBundledPlugins();
769
819
  const lastClamp = loadLastEffortClamp();
770
820
  const clampActive = effortClampAppliesToRuntime(lastClamp, resolvedRuntime.runtime);
@@ -869,7 +919,7 @@ export async function collectStatus(options: { mainAccountPolicy?: boolean } = {
869
919
 
870
920
  return {
871
921
  proxyLabel,
872
- healthLabel: health.label,
922
+ healthLabel: formatCliStatusHealthLabel(health.label, cliCommand, remoteHub.connected),
873
923
  json: {
874
924
  ...(mainAccountHardLock ? { mainAccountHardLock } : {}),
875
925
  schemaVersion: 1,
@@ -932,6 +982,8 @@ export async function collectStatus(options: { mainAccountPolicy?: boolean } = {
932
982
  // fact about this install, not about the Codex runtime, and filing it there would
933
983
  // print it under the wrong heading (#2701).
934
984
  versionSkew,
985
+ cliCommand,
986
+ startupSource,
935
987
  },
936
988
  };
937
989
  }
@@ -16,7 +16,8 @@ function parseMarker(raw: string): UpdateRestartChildMarker {
16
16
  if (!value || !Number.isSafeInteger(value.deadlineAt) || !Number.isInteger(value.port)
17
17
  || typeof value.hostname !== "string" || value.port < 1 || value.port > 65535 || typeof value.version !== "string"
18
18
  || value.version === "0.0.0" || !parseStrictSemver(value.version) || !value.home
19
- || !Number.isSafeInteger(value.home.revision)) throw new Error("update_restart_child_marker_invalid");
19
+ || !Number.isSafeInteger(value.home.revision) || value.home.serviceRecord?.schema !== 1
20
+ || typeof value.home.serviceRecord.digest !== "string" || !/^[a-f0-9]{64}$/.test(value.home.serviceRecord.digest)) throw new Error("update_restart_child_marker_invalid");
20
21
  for (const directory of [value.home.config, value.home.codex]) {
21
22
  // Windows reports 64-bit NTFS file ids (MFT sequence in the high 16 bits) as doubles above
22
23
  // 2^53. Parent and child read them through the same stat API, so any finite integer is a
@@ -56,7 +57,7 @@ export function admitUpdateRestartChild(argv: string[], io: UpdateRestartChildIo
56
57
  if (now() >= marker.deadlineAt) throw new Error("update_restart_deadline_expired");
57
58
  const bindHost = (host: string) => !host.trim() || /^localhost\.?$/i.test(host.trim()) ? "127.0.0.1" : host.trim();
58
59
  if (port !== marker.port || bindHost(hostname) !== bindHost(marker.hostname) || (io.version ?? packageVersion)() !== marker.version) throw new Error("update_restart_child_identity_changed");
59
- (io.checkHome ?? assertUpdateRestartHome)(marker.home);
60
+ (io.checkHome ?? (home => assertUpdateRestartHome(home, marker.deadlineAt)))(marker.home);
60
61
  (io.checkState ?? (expected => {
61
62
  assertUpdateRestartConfiguration(expected.hostname);
62
63
  const current = readRuntimePort();
@@ -65,6 +66,7 @@ export function admitUpdateRestartChild(argv: string[], io: UpdateRestartChildIo
65
66
  throw new Error("update_restart_competing_runtime");
66
67
  }
67
68
  }))(marker);
69
+ if (now() >= marker.deadlineAt) throw new Error("update_restart_deadline_expired");
68
70
  };
69
71
  check();
70
72
  const lease = (io.acquire ?? (waitMs => acquireOwnershipMutationLease(serviceStatePaths(), { waitMs })))(Math.min(2000, marker.deadlineAt - now()));
@@ -2,12 +2,15 @@ import { realpathSync, statSync } from "node:fs";
2
2
  import { readClientConnectionState } from "../client/state";
3
3
  import { readConfigDiagnostics } from "../config/diagnostics";
4
4
  import { getConfigDir } from "../config/paths";
5
- import { currentCodexHome, resolveServiceOwnership, resolveServiceState } from "../service/state";
5
+ import { currentCodexHome } from "../service/state";
6
+ import { captureUpdateRestartServiceRecord, type UpdateRestartServiceRecord, type UpdateRestartServiceRecordDeps } from "./update-restart-service-record";
7
+ import { probeUpdateRestartSupervision, type UpdateRestartSupervisionDeps } from "./update-restart-supervision";
6
8
 
7
9
  export interface UpdateRestartHome {
8
10
  config: { path: string; dev: number; ino: number };
9
11
  codex: { path: string; dev: number; ino: number };
10
12
  revision: number;
13
+ serviceRecord: UpdateRestartServiceRecord;
11
14
  }
12
15
 
13
16
  function physicalDirectory(path: string): UpdateRestartHome["config"] {
@@ -17,17 +20,32 @@ function physicalDirectory(path: string): UpdateRestartHome["config"] {
17
20
  return { path: canonical, dev: stat.dev, ino: stat.ino };
18
21
  }
19
22
 
20
- /** Only an unclaimed standalone home is eligible for this narrow update path. */
21
- export function readUpdateRestartHome(): UpdateRestartHome {
22
- const state = resolveServiceState();
23
- const owner = resolveServiceOwnership();
24
- if (state.kind !== "none" || owner.kind !== "none") throw new Error("update_restart_owner_unverified");
25
- return { config: physicalDirectory(getConfigDir()), codex: physicalDirectory(currentCodexHome()), revision: owner.revision };
23
+ export interface UpdateRestartHomeDeps {
24
+ record?: UpdateRestartServiceRecordDeps;
25
+ supervision?: UpdateRestartSupervisionDeps;
26
26
  }
27
27
 
28
- export function assertUpdateRestartHome(expected: UpdateRestartHome): void {
29
- const current = readUpdateRestartHome();
30
- if (JSON.stringify(current) !== JSON.stringify(expected)) throw new Error("update_restart_home_changed");
28
+ /** Capture eligibility from the same bytes that supply the frozen fingerprint. */
29
+ export function readUpdateRestartHome(deps: UpdateRestartHomeDeps = {}): UpdateRestartHome {
30
+ const { serviceRecord, state, owner } = captureUpdateRestartServiceRecord(deps.record);
31
+ const platform = deps.record?.platform ?? process.platform;
32
+ if (owner.kind !== "none" || (state.kind === "state" && platform !== "darwin" && platform !== "linux")) {
33
+ throw new Error("update_restart_owner_unverified");
34
+ }
35
+ return { config: physicalDirectory(getConfigDir()), codex: physicalDirectory(currentCodexHome()), revision: owner.revision, serviceRecord };
36
+ }
37
+
38
+ export function assertUpdateRestartHome(expected: UpdateRestartHome, deadlineAt = Date.now() + 2000, deps: UpdateRestartHomeDeps = {}): void {
39
+ try {
40
+ const current = readUpdateRestartHome(deps);
41
+ if (JSON.stringify(current) !== JSON.stringify(expected)) throw new Error("changed");
42
+ } catch { throw new Error("update_restart_home_changed"); }
43
+ if (probeUpdateRestartSupervision(deadlineAt, deps.supervision) !== "inactive") throw new Error("update_restart_supervision_unverified");
44
+ // Manager commands may yield to an external writer even though this guard is synchronous.
45
+ try {
46
+ if (JSON.stringify(readUpdateRestartHome(deps)) !== JSON.stringify(expected)) throw new Error("changed");
47
+ } catch { throw new Error("update_restart_home_changed"); }
48
+ if ((deps.supervision?.now ?? Date.now)() >= deadlineAt) throw new Error("update_restart_deadline_expired");
31
49
  }
32
50
 
33
51
  /** Read-only eligibility shared by parent pre-stop and child admission. */
@@ -0,0 +1,116 @@
1
+ import { createHash } from "node:crypto";
2
+ import { closeSync, constants, fstatSync, lstatSync, openSync, readSync, realpathSync, type BigIntStats } from "node:fs";
3
+ import { basename, dirname, join } from "node:path";
4
+ import { plistPath, resolveServiceOwnership, resolveServiceState, serviceStatePaths, type ServiceStateEvidence } from "../service/state";
5
+ import { unitPath } from "../service/systemd";
6
+ import { inspectInstallStateBytes } from "../service/install-state-contract.mjs";
7
+
8
+ export interface UpdateRestartServiceRecord { schema: 1; digest: string }
9
+ export interface UpdateRestartServiceRecordDeps {
10
+ platform?: NodeJS.Platform;
11
+ paths?: () => readonly string[];
12
+ definitionPath?: () => string;
13
+ open?: (path: string, flags: number) => number;
14
+ fstat?: (fd: number) => BigIntStats;
15
+ read?: (fd: number, buffer: Buffer, offset: number, length: number, position: number) => number;
16
+ lstat?: (path: string) => BigIntStats;
17
+ }
18
+ /** Maximum bytes per state candidate or platform definition: 1 MiB, including growth during read. */
19
+ export const UPDATE_RESTART_SERVICE_RECORD_MAX_BYTES = 1024 * 1024;
20
+ const READ_CHUNK_BYTES = 64 * 1024;
21
+ const FAILURE = "update_restart_service_record_unverified";
22
+ const hash = (bytes: string | Buffer) => createHash("sha256").update(bytes).digest("hex");
23
+ const metadata = (stat: BigIntStats) => [stat.dev, stat.ino, stat.size, stat.mtimeNs, stat.ctimeNs, stat.mode, stat.uid, stat.gid].map(String);
24
+ const absent = (error: unknown) => !!error && typeof error === "object" && "code" in error && error.code === "ENOENT";
25
+
26
+ // Canonicalize directories, never the record itself: a record symlink is a refusal.
27
+ function canonicalPath(path: string): string {
28
+ const parent = dirname(path);
29
+ try { return join(realpathSync.native(parent), basename(path)); }
30
+ catch (error) {
31
+ if (!absent(error) || parent === path) throw new Error(FAILURE);
32
+ return join(canonicalPath(parent), basename(path));
33
+ }
34
+ }
35
+
36
+ function readBoundedRecord(fd: number, read: NonNullable<UpdateRestartServiceRecordDeps["read"]>): Buffer {
37
+ const chunk = Buffer.alloc(READ_CHUNK_BYTES);
38
+ const chunks: Buffer[] = [];
39
+ let total = 0;
40
+ for (;;) {
41
+ // At the limit, a one-byte read distinguishes EOF from concurrent growth.
42
+ const length = Math.min(chunk.length, UPDATE_RESTART_SERVICE_RECORD_MAX_BYTES - total + 1);
43
+ const count = read(fd, chunk, 0, length, total);
44
+ if (!Number.isInteger(count) || count < 0 || count > length) throw new Error(FAILURE);
45
+ if (count === 0) return Buffer.concat(chunks, total);
46
+ total += count;
47
+ if (total > UPDATE_RESTART_SERVICE_RECORD_MAX_BYTES) throw new Error(FAILURE);
48
+ chunks.push(Buffer.from(chunk.subarray(0, count)));
49
+ }
50
+ }
51
+
52
+ /** Read all authority/mirror candidates and the platform definition as one frozen record. */
53
+ export function captureUpdateRestartServiceRecord(deps: UpdateRestartServiceRecordDeps = {}) {
54
+ try {
55
+ const platform = deps.platform ?? process.platform;
56
+ const paths = (deps.paths ?? serviceStatePaths)();
57
+ const definition = platform === "darwin" ? (deps.definitionPath ?? plistPath)()
58
+ : platform === "linux" ? (deps.definitionPath ?? unitPath)() : undefined;
59
+ const statPath = deps.lstat ?? (path => lstatSync(path, { bigint: true }));
60
+ const captures = [...paths, ...(definition ? [definition] : [])].map((path, index) => {
61
+ const canonical = canonicalPath(path);
62
+ let before: BigIntStats;
63
+ try { before = statPath(path); }
64
+ catch (error) {
65
+ if (!absent(error)) throw error;
66
+ return { path, canonical, index, identity: null, bytes: undefined };
67
+ }
68
+ if (!before.isFile() || before.isSymbolicLink()) throw new Error(FAILURE);
69
+ const fd = (deps.open ?? openSync)(path, constants.O_RDONLY | (platform === "win32" ? 0 : constants.O_NOFOLLOW | constants.O_NONBLOCK));
70
+ try {
71
+ const statFd = deps.fstat ?? (handle => fstatSync(handle, { bigint: true }));
72
+ const opened = statFd(fd);
73
+ const identity = metadata(before);
74
+ if (!opened.isFile() || opened.dev !== before.dev || opened.ino !== before.ino
75
+ || opened.size < 0n || opened.size > BigInt(UPDATE_RESTART_SERVICE_RECORD_MAX_BYTES)
76
+ || JSON.stringify(identity) !== JSON.stringify(metadata(opened))) throw new Error(FAILURE);
77
+ const bytes = readBoundedRecord(fd, deps.read ?? readSync);
78
+ const after = statFd(fd);
79
+ if (!after.isFile() || JSON.stringify(identity) !== JSON.stringify(metadata(after))
80
+ || BigInt(bytes.length) !== after.size) throw new Error(FAILURE);
81
+ return { path, canonical, index, identity, bytes };
82
+ } finally { closeSync(fd); }
83
+ });
84
+ // Pin path-to-descriptor identity after every read; also confirm each ENOENT again.
85
+ for (const entry of captures) {
86
+ let current: BigIntStats;
87
+ try { current = statPath(entry.path); }
88
+ catch (error) { if (entry.identity === null && absent(error)) continue; throw error; }
89
+ if (!current.isFile() || JSON.stringify(metadata(current)) !== JSON.stringify(entry.identity)
90
+ || canonicalPath(entry.path) !== entry.canonical) throw new Error(FAILURE);
91
+ }
92
+ const evidence: ServiceStateEvidence[] = captures.slice(0, paths.length).map(entry => {
93
+ if (!entry.bytes) return { path: entry.canonical, kind: "absent" };
94
+ const parsed = inspectInstallStateBytes(entry.canonical, () => entry.bytes!.toString("utf8"));
95
+ if (parsed.kind !== "valid") throw new Error(FAILURE);
96
+ return parsed as ServiceStateEvidence;
97
+ });
98
+ const state = resolveServiceState(evidence);
99
+ const owner = resolveServiceOwnership(evidence);
100
+ if (state.kind === "unknown" || owner.kind === "unknown") throw new Error(FAILURE);
101
+ const serviceRecord: UpdateRestartServiceRecord = { schema: 1, digest: hash(JSON.stringify({
102
+ platform, authority: paths.length - 1,
103
+ records: captures.map(entry => [entry.canonical, entry.index, entry.identity, entry.bytes ? hash(entry.bytes) : null]),
104
+ })) };
105
+ return { serviceRecord, state, owner };
106
+ } catch { throw new Error(FAILURE); }
107
+ }
108
+
109
+ /** Drift is terminal; never replace the transaction's original digest. */
110
+ export function assertUpdateRestartServiceRecord(expected: UpdateRestartServiceRecord, deps: UpdateRestartServiceRecordDeps = {}) {
111
+ try {
112
+ const current = captureUpdateRestartServiceRecord(deps);
113
+ if (expected.schema !== 1 || current.serviceRecord.digest !== expected.digest) throw new Error(FAILURE);
114
+ return current;
115
+ } catch { throw new Error("update_restart_home_changed"); }
116
+ }
@@ -0,0 +1,96 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { statSync, type Stats } from "node:fs";
3
+ import { systemdUserBusEnvironment } from "../service/systemd";
4
+ import { assertLiveServiceManagerAllowed } from "../service/guards";
5
+ import { LABEL, TASK } from "../service/state";
6
+
7
+ export type UpdateRestartSupervision = "inactive" | "active" | "unknown";
8
+ export interface UpdateRestartSupervisorReply { status: number | null; stdout: string; stderr: string }
9
+ export interface UpdateRestartSupervisionDeps {
10
+ platform?: NodeJS.Platform;
11
+ now?: () => number;
12
+ uid?: number;
13
+ stat?: (path: string) => Pick<Stats, "isFile" | "mode">;
14
+ environment?: NodeJS.ProcessEnv;
15
+ exists?: (path: string) => boolean;
16
+ run?: (command: string, args: string[], timeoutMs: number, environment: NodeJS.ProcessEnv) => UpdateRestartSupervisorReply;
17
+ }
18
+ const PROBE_TIMEOUT_MS = 2000;
19
+ export const UPDATE_RESTART_SYSTEMD_ARGS = ["--user", "show", "-p", "LoadState", "-p", "ActiveState", "-p", "MainPID", "-p", "FragmentPath", "-p", "NeedDaemonReload", TASK];
20
+
21
+ function run(command: string, args: string[], timeout: number, env: NodeJS.ProcessEnv): UpdateRestartSupervisorReply {
22
+ assertLiveServiceManagerAllowed("update restart supervision");
23
+ try {
24
+ return { status: 0, stdout: execFileSync(command, args, { encoding: "utf8", timeout, env,
25
+ killSignal: "SIGKILL", maxBuffer: 64 * 1024, stdio: ["ignore", "pipe", "pipe"] }), stderr: "" };
26
+ } catch (error) {
27
+ const failure = error as { status?: number | null; signal?: string; code?: string; stdout?: Buffer; stderr?: Buffer };
28
+ if (failure.signal || failure.code || !Number.isInteger(failure.status)) throw new Error("update_restart_supervision_unverified");
29
+ return { status: failure.status!, stdout: failure.stdout?.toString() ?? "", stderr: failure.stderr?.toString() ?? "" };
30
+ }
31
+ }
32
+
33
+ /** Ignore PATH; usr-merged systemctl symlinks may resolve to a regular trusted-location target. */
34
+ export function resolveUpdateRestartSupervisor(deps: UpdateRestartSupervisionDeps = {}): string | null {
35
+ const platform = deps.platform ?? process.platform;
36
+ const paths = platform === "linux" ? ["/usr/bin/systemctl", "/bin/systemctl"] : platform === "darwin" ? ["/bin/launchctl"] : [];
37
+ for (const path of paths) {
38
+ try {
39
+ const stat = (deps.stat ?? statSync)(path);
40
+ if (stat.isFile() && (stat.mode & 0o002) === 0 && (stat.mode & 0o111) !== 0) return path;
41
+ } catch { /* Try the next trusted location; no PATH fallback. */ }
42
+ }
43
+ return null;
44
+ }
45
+
46
+ /** All manager evidence shares the transaction deadline, including the retained PID-bound probe. */
47
+ export function runBoundedUpdateRestartSupervisor(command: string, args: string[], deadlineAt: number, deps: UpdateRestartSupervisionDeps = {}) {
48
+ const now = deps.now ?? Date.now;
49
+ const remaining = deadlineAt - now();
50
+ if (!Number.isFinite(remaining) || remaining <= 0) throw new Error("update_restart_supervision_unverified");
51
+ const environment = (deps.platform ?? process.platform) === "linux"
52
+ ? systemdUserBusEnvironment(deps.environment ?? process.env, { uid: deps.uid, exists: deps.exists })
53
+ : { ...(deps.environment ?? process.env) };
54
+ const result = (deps.run ?? run)(command, args, Math.min(PROBE_TIMEOUT_MS, remaining), environment);
55
+ if (now() >= deadlineAt) throw new Error("update_restart_supervision_unverified");
56
+ return result;
57
+ }
58
+
59
+ /** Registration presence is independent of liveness; only positive inactivity admits. */
60
+ export function probeUpdateRestartSupervision(deadlineAt: number, deps: UpdateRestartSupervisionDeps = {}, command = resolveUpdateRestartSupervisor(deps)): UpdateRestartSupervision {
61
+ const platform = deps.platform ?? process.platform;
62
+ if (!command) return "unknown";
63
+ if (platform === "darwin") {
64
+ const uid = deps.uid ?? process.getuid?.() ?? 0;
65
+ const states = [`gui/${uid}/${LABEL}`, `user/${uid}/${LABEL}`].map(target => {
66
+ try {
67
+ const result = runBoundedUpdateRestartSupervisor(command, ["print", target], deadlineAt, deps);
68
+ return result.status === 112 || result.status === 113 ? "inactive" : result.status === 0 ? "active" : "unknown";
69
+ } catch { return "unknown"; }
70
+ });
71
+ return states.includes("unknown") ? "unknown" : states.includes("active") ? "active" : "inactive";
72
+ }
73
+ if (platform === "linux") {
74
+ try {
75
+ const result = runBoundedUpdateRestartSupervisor(command, UPDATE_RESTART_SYSTEMD_ARGS, deadlineAt, deps);
76
+ return classifyUpdateRestartSystemdSupervision(result);
77
+ } catch { return "unknown"; }
78
+ }
79
+ return "unknown";
80
+ }
81
+
82
+ /** The retained manager observation must satisfy the same positive-inactivity contract. */
83
+ export function classifyUpdateRestartSystemdSupervision(result: UpdateRestartSupervisorReply): UpdateRestartSupervision {
84
+ if (result.status !== 0) return "unknown";
85
+ const fields = new Map<string, string>();
86
+ for (const line of result.stdout.trim().split(/\r?\n/)) {
87
+ const match = /^([A-Za-z]+)=(.*)$/.exec(line);
88
+ if (!match || fields.has(match[1]!)) return "unknown";
89
+ fields.set(match[1]!, match[2]!);
90
+ }
91
+ const pid = fields.get("MainPID");
92
+ if (!pid || !/^\d+$/.test(pid) || !Number.isSafeInteger(Number(pid))) return "unknown";
93
+ if (!["loaded", "not-found"].includes(fields.get("LoadState") ?? "")) return "unknown";
94
+ if (fields.get("ActiveState") === "inactive" && pid === "0") return "inactive";
95
+ return fields.get("ActiveState") === "active" || Number(pid) > 0 ? "active" : "unknown";
96
+ }