@bitkyc08/opencodex 2.90.0 → 2.92.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 (250) hide show
  1. package/bin/ocx.mjs +37 -3
  2. package/gui/dist/assets/App-DMps4OLk.css +1 -0
  3. package/gui/dist/assets/{App-DgxkE6Md.js → App-sUf_8afJ.js} +18 -18
  4. package/gui/dist/assets/{Tray-DbTNc_88.js → Tray-BSsxznDm.js} +1 -1
  5. package/gui/dist/assets/index-BlFE_VB_.js +94 -0
  6. package/gui/dist/assets/quota-summary-BRlbXgw0.js +1 -0
  7. package/gui/dist/index.html +1 -1
  8. package/gui/dist/provider-icons/workbuddy-color.svg +29 -0
  9. package/package.json +4 -3
  10. package/src/adapters/anthropic.ts +2 -5
  11. package/src/adapters/claude-cli/usage-admission.ts +14 -0
  12. package/src/adapters/google-vertex-endpoint.ts +9 -1
  13. package/src/adapters/google-vertex-key-project.ts +223 -0
  14. package/src/adapters/google.ts +13 -3
  15. package/src/adapters/openai-chat/tool-name-registry.ts +3 -1
  16. package/src/adapters/openai-chat.ts +2 -2
  17. package/src/adapters/openai-responses/passthrough.ts +2 -1
  18. package/src/adapters/openai-responses/request-strips.ts +92 -0
  19. package/src/adapters/openai-responses/transport.ts +6 -0
  20. package/src/adapters/registry.ts +5 -0
  21. package/src/adapters/workbuddy-schema.ts +61 -0
  22. package/src/adapters/workbuddy.ts +68 -0
  23. package/src/bridge/response-json.ts +1 -1
  24. package/src/bridge/sse.ts +1 -1
  25. package/src/chat/inbound.ts +20 -2
  26. package/src/chat/outbound.ts +47 -8
  27. package/src/chat/reasoning-details.ts +137 -0
  28. package/src/cli/capabilities-agents-routing.ts +53 -3
  29. package/src/cli/capabilities-base.ts +15 -2
  30. package/src/cli/capabilities-observe-system.ts +4 -4
  31. package/src/cli/codex-cli-update.ts +8 -0
  32. package/src/cli/codex-shim-autorestore.ts +5 -0
  33. package/src/cli/combo-result.ts +6 -0
  34. package/src/cli/combo-stats.ts +3 -0
  35. package/src/cli/combo.ts +66 -42
  36. package/src/cli/dispatch.ts +30 -0
  37. package/src/cli/fallback.ts +127 -0
  38. package/src/cli/help.ts +1 -0
  39. package/src/cli/index.ts +1 -2
  40. package/src/cli/launcher-context.ts +49 -2
  41. package/src/cli/link-apply-restart.ts +72 -0
  42. package/src/cli/link-child.ts +6 -2
  43. package/src/cli/link-cleanup.ts +95 -0
  44. package/src/cli/link-enrollment.ts +4 -0
  45. package/src/cli/link.ts +123 -35
  46. package/src/cli/provider.ts +46 -3
  47. package/src/cli/registry.ts +13 -1
  48. package/src/cli/restart-failure.ts +4 -0
  49. package/src/cli/runtime-api.ts +1 -1
  50. package/src/cli/status-codex-settings.ts +56 -0
  51. package/src/cli/status-fallback.ts +29 -0
  52. package/src/cli/status-install.ts +52 -0
  53. package/src/cli/status.ts +20 -1
  54. package/src/cli/system-restart-client.ts +32 -1
  55. package/src/cli/update-restart-child.ts +4 -3
  56. package/src/cli/update-restart-home.ts +20 -8
  57. package/src/cli/usage-compare.ts +68 -12
  58. package/src/client/connect.ts +82 -6
  59. package/src/client/home-unreachable.ts +16 -0
  60. package/src/client/hub-relay.ts +8 -2
  61. package/src/client/link-home.ts +119 -6
  62. package/src/client/link-ingress.ts +1 -1
  63. package/src/client/link-parity-reporter.ts +107 -0
  64. package/src/client/link-readiness.ts +97 -0
  65. package/src/client/link-relay-transport.ts +84 -9
  66. package/src/client/link-relay.ts +98 -15
  67. package/src/client/link-tunnel.ts +45 -18
  68. package/src/client/machine-listener.ts +103 -3
  69. package/src/client/runtime-restart.ts +127 -0
  70. package/src/client/runtime.ts +155 -25
  71. package/src/codex/account-store.ts +17 -3
  72. package/src/codex/app-server-restart-service.ts +6 -5
  73. package/src/codex/auth-context.ts +10 -6
  74. package/src/codex/catalog/effort.ts +9 -2
  75. package/src/codex/catalog/gather-capture.ts +4 -2
  76. package/src/codex/catalog/parsing.ts +4 -0
  77. package/src/codex/catalog/provider-models.ts +14 -8
  78. package/src/codex/chatgpt-refresh-failure.ts +6 -2
  79. package/src/codex/cli-install-provenance.ts +418 -0
  80. package/src/codex/cli-installation-targets.ts +5 -1
  81. package/src/codex/cli-update-plan.ts +43 -16
  82. package/src/codex/desktop-app/picker-freshness.ts +10 -8
  83. package/src/codex/desktop-compatibility/usage-activation.ts +2 -1
  84. package/src/codex/desktop-compatibility/usage-controller.ts +7 -1
  85. package/src/codex/desktop-compatibility/windows-key-protection.ts +18 -6
  86. package/src/codex/pool-refresh-backoff.ts +3 -1
  87. package/src/codex/quota-history.ts +7 -0
  88. package/src/codex/quota.ts +10 -0
  89. package/src/codex/subagent-model-fallback.ts +24 -1
  90. package/src/codex/user-identity.ts +35 -6
  91. package/src/codex/windows-current-sid.ts +162 -0
  92. package/src/combos/index.ts +1 -0
  93. package/src/combos/jev-quota-activation.ts +5 -1
  94. package/src/combos/jev-quota-route.ts +1 -1
  95. package/src/combos/jev-quota.ts +9 -1
  96. package/src/combos/jev-session-pin-config.ts +51 -0
  97. package/src/combos/jev.ts +6 -0
  98. package/src/combos/resolve.ts +12 -0
  99. package/src/combos/types.ts +19 -0
  100. package/src/config/diagnostics.ts +5 -0
  101. package/src/config/load-degrade.ts +2 -0
  102. package/src/config/provider-validation.ts +7 -0
  103. package/src/config/schema/config-schema.ts +3 -0
  104. package/src/config/schema/fallback-policy.ts +40 -0
  105. package/src/config/schema/leaf-validators.ts +1 -0
  106. package/src/generated/compatibility-version.json +375 -195
  107. package/src/lib/authorization-scheme.ts +95 -0
  108. package/src/lib/decision-quota-sink.ts +10 -0
  109. package/src/lib/provider-client-headers.ts +1 -0
  110. package/src/lib/redact.ts +2 -2
  111. package/src/lib/system-restart-contract.ts +24 -0
  112. package/src/lib/test-home-guard.ts +78 -11
  113. package/src/lib/translator-budget.ts +4 -4
  114. package/src/lib/upstream-retry.ts +4 -1
  115. package/src/link/child-parity.ts +124 -0
  116. package/src/link/codex-settings.ts +37 -0
  117. package/src/link/compatibility.ts +119 -0
  118. package/src/link/diagnostics.ts +193 -0
  119. package/src/link/management-routes.ts +2 -0
  120. package/src/link/peer-report.ts +100 -0
  121. package/src/link/relay-auth.ts +6 -0
  122. package/src/link/remote-platform.ts +78 -0
  123. package/src/link/ssh-argv.ts +19 -0
  124. package/src/link/status-projection.ts +63 -2
  125. package/src/link/supervisor.ts +45 -12
  126. package/src/link/tunnel-history.ts +109 -0
  127. package/src/link/tunnel-state.ts +80 -17
  128. package/src/messaging/remote-files.ts +24 -1
  129. package/src/oauth/generic-account-failover.ts +91 -7
  130. package/src/oauth/index.ts +16 -1
  131. package/src/oauth/kimi.ts +2 -0
  132. package/src/oauth/model-discovery-auth.ts +5 -0
  133. package/src/oauth/nous.ts +4 -1
  134. package/src/oauth/store.ts +6 -2
  135. package/src/oauth/token-guardian.ts +7 -0
  136. package/src/oauth/types.ts +9 -0
  137. package/src/oauth/workbuddy.ts +139 -0
  138. package/src/protocols/codecs/chat.ts +3 -3
  139. package/src/protocols/encoders/adapter-events.ts +26 -2
  140. package/src/protocols/encoders/chat.ts +55 -3
  141. package/src/providers/antigravity-quota-identity.ts +37 -0
  142. package/src/providers/base-url-choices.ts +8 -5
  143. package/src/providers/derive.ts +23 -3
  144. package/src/providers/mistral-transport.ts +37 -4
  145. package/src/providers/model-discovery.ts +2 -0
  146. package/src/providers/model-rename-fields.ts +1 -0
  147. package/src/providers/quota/account-cache.ts +3 -2
  148. package/src/providers/quota/antigravity.ts +27 -22
  149. package/src/providers/quota/report-cache.ts +13 -4
  150. package/src/providers/quota/vendor-probes-key.ts +60 -0
  151. package/src/providers/quota/workbuddy.ts +83 -0
  152. package/src/providers/quota-decision-antigravity.ts +64 -0
  153. package/src/providers/quota-decision-publication.ts +11 -0
  154. package/src/providers/quota-decision-snapshot.ts +21 -4
  155. package/src/providers/quota-types.ts +2 -0
  156. package/src/providers/quota.ts +3 -1
  157. package/src/providers/registry/entries-core.ts +9 -0
  158. package/src/providers/registry/entries-extended.ts +20 -7
  159. package/src/providers/registry/model-ids.ts +3 -0
  160. package/src/providers/registry/model-seeds.ts +27 -28
  161. package/src/providers/registry/types.ts +19 -1
  162. package/src/providers/resolved-model-policy.ts +4 -4
  163. package/src/providers/routed-config.ts +15 -0
  164. package/src/providers/stale-model-roster-migration.ts +83 -15
  165. package/src/providers/workbuddy-credit-balance.ts +81 -0
  166. package/src/providers/workbuddy-models.ts +37 -0
  167. package/src/providers/workbuddy.ts +60 -0
  168. package/src/quota-pool/assemble.ts +326 -0
  169. package/src/quota-pool/contract.ts +58 -0
  170. package/src/quota-pool/identity.ts +203 -0
  171. package/src/remote/features.ts +74 -0
  172. package/src/remote/protocol.ts +2 -0
  173. package/src/remote/served-by.ts +57 -0
  174. package/src/responses/parser.ts +7 -0
  175. package/src/responses/reasoning-envelope.ts +12 -0
  176. package/src/responses/reasoning-replay-cache.ts +18 -14
  177. package/src/router.ts +2 -0
  178. package/src/routing/analytics.ts +2 -3
  179. package/src/routing/fallback-policy.ts +290 -0
  180. package/src/routing/fallback-state.ts +179 -0
  181. package/src/server/audio-client.ts +2 -1
  182. package/src/server/audio-dictation.ts +2 -1
  183. package/src/server/audio-upstream.ts +2 -1
  184. package/src/server/auth-cors.ts +7 -5
  185. package/src/server/background-lifecycle.ts +4 -0
  186. package/src/server/chat-completions.ts +22 -2
  187. package/src/server/chat-native-reasoning.ts +10 -3
  188. package/src/server/chat-native.ts +13 -2
  189. package/src/server/claude-messages.ts +3 -0
  190. package/src/server/gui-session.ts +2 -1
  191. package/src/server/index/link-listener.ts +11 -1
  192. package/src/server/index/link-management.ts +47 -0
  193. package/src/server/index/link-relay-sessions.ts +20 -7
  194. package/src/server/index/optional-listeners.ts +11 -2
  195. package/src/server/index/serve-options.ts +19 -2
  196. package/src/server/live.ts +5 -0
  197. package/src/server/local-management-read-client.ts +3 -1
  198. package/src/server/management/combo-routes.ts +10 -4
  199. package/src/server/management/context.ts +1 -1
  200. package/src/server/management/fallback-routes.ts +75 -0
  201. package/src/server/management/link-enrollment.ts +21 -3
  202. package/src/server/management/link-route-state.ts +2 -0
  203. package/src/server/management/link-routes.ts +16 -2
  204. package/src/server/management/provider-routes.ts +1 -1
  205. package/src/server/management/quota-pool-routes.ts +24 -0
  206. package/src/server/management/route-registry.ts +3 -0
  207. package/src/server/management-api.ts +16 -0
  208. package/src/server/management-auth.ts +2 -1
  209. package/src/server/port-reclaim.ts +51 -9
  210. package/src/server/request-log-conversation.ts +49 -0
  211. package/src/server/request-log.ts +2 -0
  212. package/src/server/responses/agent-task-recovery.ts +1 -1
  213. package/src/server/responses/anthropic-thinking-recovery.ts +1 -0
  214. package/src/server/responses/compact.ts +12 -4
  215. package/src/server/responses/core-auth.ts +11 -1
  216. package/src/server/responses/core-combo.ts +125 -32
  217. package/src/server/responses/core-lifetime.ts +2 -2
  218. package/src/server/responses/core-normalize.ts +6 -3
  219. package/src/server/responses/core-opaque-recovery.ts +1 -0
  220. package/src/server/responses/core-options.ts +5 -0
  221. package/src/server/responses/core-replay.ts +2 -1
  222. package/src/server/responses/encrypted-payload.ts +3 -3
  223. package/src/server/responses/jev-combo-choice.ts +39 -0
  224. package/src/server/responses/jev-session-pin.ts +203 -0
  225. package/src/server/responses/main-hard-lock-routing.ts +54 -2
  226. package/src/server/responses/passthrough-delivery.ts +4 -0
  227. package/src/server/responses/policy-fallback.ts +44 -1
  228. package/src/server/responses/request-prepare.ts +19 -6
  229. package/src/server/responses/request-transport.ts +25 -6
  230. package/src/server/responses/run-turn-execution.ts +2 -4
  231. package/src/server/responses/sidecar-execution.ts +2 -4
  232. package/src/server/responses-phase-inference.ts +267 -0
  233. package/src/server/restart-replacement.ts +50 -2
  234. package/src/server/sse-payload-rewrite.ts +14 -1
  235. package/src/server/ws-bridge.ts +5 -0
  236. package/src/service/cli.ts +25 -2
  237. package/src/service/definition.ts +20 -8
  238. package/src/service/diagnostics.ts +20 -6
  239. package/src/service/status-json.ts +116 -0
  240. package/src/types/config.ts +13 -0
  241. package/src/types/fallback.ts +38 -0
  242. package/src/types/provider.ts +9 -1
  243. package/src/types/request.ts +1 -0
  244. package/src/types/wire.ts +1 -0
  245. package/src/update/codex-cli-update-launch-policy.d.mts +9 -0
  246. package/src/update/codex-cli-update-launch-policy.mjs +30 -0
  247. package/src/usage/jev-stats.ts +54 -14
  248. package/gui/dist/assets/App-dnkJ85te.css +0 -1
  249. package/gui/dist/assets/index-TmaHa54u.js +0 -94
  250. package/gui/dist/assets/quota-summary-B16x5DZZ.js +0 -1
@@ -0,0 +1,267 @@
1
+ import type { OcxMessagePhase } from "../types";
2
+ import { Buffer } from "node:buffer";
3
+ import type { TranslatorBudget } from "../lib/translator-budget";
4
+ import type { SseBlockRewrite } from "./sse-payload-rewrite";
5
+ import { replaceSseDataPayload, sseDataPayload } from "./sse-payload-rewrite";
6
+
7
+ /**
8
+ * Opt-in per-provider phase inference for Responses passthrough streams.
9
+ *
10
+ * OpenAI models annotate assistant messages with a commentary/final_answer
11
+ * phase; the Codex desktop UI collapses commentary into progress rows and
12
+ * only fully renders the final answer. Third-party gateways forward plain
13
+ * Responses items without that field, so every narration chunk renders
14
+ * expanded. The wire has no phase field, so phase is inferred from what
15
+ * follows each completed message, mirroring the openai-chat bridge strategy:
16
+ * a message followed by tool-call work is commentary; a message at turn end
17
+ * is the final answer. Messages already carrying commentary or final_answer
18
+ * are left untouched and forwarded without buffering; a null or unknown phase
19
+ * is inferred like a missing one. Each rewritten message stays
20
+ * buffered until the next protocol event (SSE keepalives keep it pending).
21
+ * Live text deltas are not buffered, but message completion waits for that
22
+ * event. Snapshots reuse each item decision when their phase is absent,
23
+ * matched by item id or, when either side lacks an id, by output index;
24
+ * explicit snapshot phases take precedence, and failed snapshots are untouched.
25
+ */
26
+
27
+ const TERMINAL_EVENT_TYPES = new Set([
28
+ "response.completed",
29
+ "response.incomplete",
30
+ "response.failed",
31
+ ]);
32
+ const TOOL_CALL_ITEM_TYPES = new Set([
33
+ "function_call",
34
+ "custom_tool_call",
35
+ "local_shell_call",
36
+ "shell_call",
37
+ "apply_patch_call",
38
+ "mcp_call",
39
+ "tool_search_call",
40
+ "web_search_call",
41
+ ]);
42
+
43
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
44
+ return !!value && typeof value === "object" && !Array.isArray(value);
45
+ }
46
+
47
+ /** Only these two values are decisions; null or unknown phases count as missing. */
48
+ function isMessagePhase(value: unknown): value is OcxMessagePhase {
49
+ return value === "commentary" || value === "final_answer";
50
+ }
51
+
52
+ function isToolCallItem(item: unknown): boolean {
53
+ if (!isPlainObject(item)) return false;
54
+ const type = item.type;
55
+ return typeof type === "string"
56
+ && (TOOL_CALL_ITEM_TYPES.has(type)
57
+ // Unknown hosted/extension call shapes still prove the turn continued.
58
+ || (type.endsWith("_call") && !type.endsWith("_call_output")));
59
+ }
60
+
61
+ function parseDataPayload(block: string): Record<string, unknown> | null {
62
+ const payload = sseDataPayload(block);
63
+ if (payload === null) return null;
64
+ try {
65
+ const parsed = JSON.parse(payload) as unknown;
66
+ return isPlainObject(parsed) ? parsed : null;
67
+ } catch {
68
+ return null;
69
+ }
70
+ }
71
+
72
+ function isDoneSentinel(block: string): boolean {
73
+ return sseDataPayload(block)?.trim() === "[DONE]";
74
+ }
75
+
76
+ function isMessageDoneBlock(block: string): boolean {
77
+ const parsed = parseDataPayload(block);
78
+ return parsed?.type === "response.output_item.done"
79
+ && isPlainObject(parsed.item) && parsed.item.type === "message";
80
+ }
81
+
82
+ function withMessagePhase(block: string, phase: OcxMessagePhase): string {
83
+ const parsed = parseDataPayload(block);
84
+ if (!parsed || parsed.type !== "response.output_item.done") return block;
85
+ if (!isPlainObject(parsed.item) || isMessagePhase(parsed.item.phase)) return block;
86
+ const patched = { ...parsed, item: { ...parsed.item, phase } };
87
+ return replaceSseDataPayload(block, JSON.stringify(patched));
88
+ }
89
+
90
+ /** One per-item decision, findable by item id and by output index. */
91
+ interface PhaseDecision {
92
+ readonly id: string | null;
93
+ readonly index: number | null;
94
+ readonly phase: OcxMessagePhase;
95
+ }
96
+
97
+ interface PhaseDecisions {
98
+ readonly byId: ReadonlyMap<string, PhaseDecision>;
99
+ readonly byIndex: ReadonlyMap<number, PhaseDecision>;
100
+ }
101
+
102
+ function outputIndexOf(value: unknown): number | null {
103
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : null;
104
+ }
105
+
106
+ /**
107
+ * The phase already emitted on the wire for the snapshot item at `position`.
108
+ * An id match wins. A position match is used only when the event or the
109
+ * snapshot item lacks an id, so two different ids never share a decision.
110
+ */
111
+ function decidedPhase(decisions: PhaseDecisions, id: string | null, position: number): OcxMessagePhase | undefined {
112
+ const byId = id !== null ? decisions.byId.get(id) : undefined;
113
+ if (byId) return byId.phase;
114
+ const byIndex = decisions.byIndex.get(position);
115
+ return byIndex && (byIndex.id === null || id === null) ? byIndex.phase : undefined;
116
+ }
117
+
118
+ function withSnapshotPhases(block: string, decisions: PhaseDecisions): string {
119
+ const parsed = parseDataPayload(block);
120
+ if (!parsed) return block;
121
+ const response = parsed.response;
122
+ if (!isPlainObject(response) || !Array.isArray(response.output)) return block;
123
+ let changed = false;
124
+ const output = response.output.map((item: unknown, position: number) => {
125
+ if (isPlainObject(item) && item.type === "message" && !isMessagePhase(item.phase)) {
126
+ // Mirror the decision already emitted for this item on the wire, so
127
+ // clients reconciling the final snapshot never re-expand a message
128
+ // whose output_item.done said commentary. Items the rewrite never saw
129
+ // (joined mid-stream, no buffered decision) fall back to final_answer.
130
+ const id = typeof item.id === "string" ? item.id : null;
131
+ const phase = decidedPhase(decisions, id, position) ?? "final_answer";
132
+ changed = true;
133
+ return { ...item, phase };
134
+ }
135
+ return item;
136
+ });
137
+ if (!changed) return block;
138
+ const patched = { ...parsed, response: { ...response, output } };
139
+ return replaceSseDataPayload(block, JSON.stringify(patched));
140
+ }
141
+
142
+ export function createResponsesPhaseInferenceBlockRewrite(budget: TranslatorBudget): SseBlockRewrite {
143
+ // At most one message-done block is held at a time; the next block decides
144
+ // its phase and is then processed itself. Malformed blocks pass through
145
+ // without consuming the pending decision. Per-item decisions fill missing
146
+ // snapshot phases. Both pending blocks and decisions share the request
147
+ // retention budget and are released at a terminal event or teardown.
148
+ let pending: string | null = null;
149
+ let pendingBytes = 0;
150
+ let decisionBytes = 0;
151
+ const byId = new Map<string, PhaseDecision>();
152
+ const byIndex = new Map<number, PhaseDecision>();
153
+ const decisions: PhaseDecisions = { byId, byIndex };
154
+ const scope = { kind: "retained_collectors" as const };
155
+
156
+ const clearState = (): void => {
157
+ budget.releaseRetained(pendingBytes + decisionBytes, scope);
158
+ pending = null;
159
+ pendingBytes = 0;
160
+ decisionBytes = 0;
161
+ byId.clear();
162
+ byIndex.clear();
163
+ };
164
+
165
+ const decisionSize = (decision: PhaseDecision): number =>
166
+ Buffer.byteLength(JSON.stringify([decision.id, decision.index, decision.phase]));
167
+
168
+ const rememberPhase = (item: unknown, outputIndex: unknown, phase: OcxMessagePhase): void => {
169
+ const id = isPlainObject(item) && typeof item.id === "string" ? item.id : null;
170
+ const index = outputIndexOf(outputIndex);
171
+ if (id === null && index === null) return;
172
+ const next: PhaseDecision = { id, index, phase };
173
+ const replaced = new Set<PhaseDecision>();
174
+ const previousById = id !== null ? byId.get(id) : undefined;
175
+ const previousByIndex = index !== null ? byIndex.get(index) : undefined;
176
+ if (previousById) replaced.add(previousById);
177
+ if (previousByIndex) replaced.add(previousByIndex);
178
+ let delta = decisionSize(next);
179
+ for (const previous of replaced) delta -= decisionSize(previous);
180
+ // Charge before mutating, so a budget rejection leaves state consistent.
181
+ if (delta > 0) budget.chargeRetained(delta, scope);
182
+ else if (delta < 0) budget.releaseRetained(-delta, scope);
183
+ for (const previous of replaced) {
184
+ if (previous.id !== null && byId.get(previous.id) === previous) byId.delete(previous.id);
185
+ if (previous.index !== null && byIndex.get(previous.index) === previous) byIndex.delete(previous.index);
186
+ }
187
+ if (id !== null) byId.set(id, next);
188
+ if (index !== null) byIndex.set(index, next);
189
+ decisionBytes += delta;
190
+ };
191
+
192
+ const releasePending = (phase: OcxMessagePhase): readonly string[] => {
193
+ if (pending === null) return [];
194
+ const block = pending;
195
+ const parsed = parseDataPayload(block);
196
+ rememberPhase(parsed?.item, parsed?.output_index, phase);
197
+ const patched = withMessagePhase(block, phase);
198
+ budget.releaseRetained(pendingBytes, scope);
199
+ pending = null;
200
+ pendingBytes = 0;
201
+ return [patched];
202
+ };
203
+
204
+ const rewrite = (block: string): readonly string[] => {
205
+ const parsed = parseDataPayload(block);
206
+ const type = typeof parsed?.type === "string" ? parsed.type : null;
207
+ if (TERMINAL_EVENT_TYPES.has(type!)) {
208
+ // Turn end: any still-buffered message is the final answer, and the
209
+ // terminal snapshot mirrors the same per-item decisions for consistency.
210
+ const released = releasePending("final_answer");
211
+ const snapshot = type === "response.failed" ? block : withSnapshotPhases(block, decisions);
212
+ clearState();
213
+ return [...released, snapshot];
214
+ }
215
+ if (type === "response.output_item.done" && isMessageDoneBlock(block)) {
216
+ const item = parsed?.item;
217
+ if (isPlainObject(item) && isMessagePhase(item.phase)) {
218
+ // An explicit upstream phase needs no inference: forward immediately
219
+ // instead of buffering a message that an abnormal teardown would
220
+ // otherwise drop before it was ever emitted.
221
+ const released = releasePending("commentary");
222
+ rememberPhase(item, parsed?.output_index, item.phase);
223
+ return [...released, block];
224
+ }
225
+ // A following message done means the turn continued past the buffered
226
+ // one (a phase change or a guarded continuation): commentary. Buffer
227
+ // the current block for classification by whatever comes next.
228
+ const released = releasePending("commentary");
229
+ const bytes = Buffer.byteLength(block);
230
+ budget.chargeRetained(bytes, scope);
231
+ pending = block;
232
+ pendingBytes = bytes;
233
+ return released;
234
+ }
235
+ if (type === null) {
236
+ // Comment keepalives and other non-JSON data blocks carry no
237
+ // classification evidence in either direction, so a still-buffered
238
+ // message stays pending across them. The [DONE] sentinel closes the
239
+ // stream cleanly, which proves the buffered message was the turn's
240
+ // final answer.
241
+ if (isDoneSentinel(block)) {
242
+ const released = releasePending("final_answer");
243
+ clearState();
244
+ return [...released, block];
245
+ }
246
+ return [block];
247
+ }
248
+ // Any other protocol event after a completed message proves the turn
249
+ // continued past it (tool calls, reasoning, a new item): commentary.
250
+ return [...releasePending("commentary"), block];
251
+ };
252
+
253
+ return Object.assign((block: string): readonly string[] => {
254
+ try { return rewrite(block); }
255
+ catch (error) {
256
+ clearState();
257
+ throw error;
258
+ }
259
+ }, {
260
+ dispose: () => {
261
+ // Relay teardown after a terminal event leaves nothing pending. An
262
+ // abnormal teardown before classification drops the retained block,
263
+ // since the stream is failing anyway and no phase was ever proven.
264
+ clearState();
265
+ },
266
+ });
267
+ }
@@ -214,6 +214,9 @@ export interface ReplacementStartIo extends ReplacementReadinessIo {
214
214
  logPath?: string | null;
215
215
  parentPid?: number;
216
216
  retryDelayMs?: number;
217
+ /** Exit waits for a terminated not-ready replacement; tests shorten them. */
218
+ termWaitMs?: number;
219
+ killWaitMs?: number;
217
220
  }
218
221
 
219
222
  export interface ReplacementStartRequest {
@@ -227,10 +230,19 @@ export interface ReplacementStartRequest {
227
230
  waitForHealth: boolean;
228
231
  /** The replacement's environment, already prepared by the caller; the parent marker is added here. */
229
232
  env: NodeJS.ProcessEnv;
233
+ /**
234
+ * What a replacement still unready at the deadline means. `handoff` (the default, every
235
+ * standalone restart) hands off to it anyway, because the parent is about to exit and release
236
+ * what it may be waiting for. `terminate` is for a parent that keeps serving when the
237
+ * replacement fails (the client runtime): the replacement is stopped, its exit is observed, and
238
+ * the call rejects with `not_ready`, so nothing that may still bind the port survives.
239
+ */
240
+ notReady?: "handoff" | "terminate";
230
241
  }
231
242
 
232
243
  type AttemptOutcome =
233
- | { kind: "spawned" | "ready" | "not-ready"; pid: number | undefined }
244
+ | { kind: "spawned" | "ready"; pid: number | undefined }
245
+ | { kind: "not-ready"; pid: number | undefined; terminate: () => Promise<boolean> }
234
246
  | { kind: "early-exit"; pid: number | undefined; code: number | null; signal: NodeJS.Signals | null };
235
247
 
236
248
  function pinnedPort(port: number | undefined): number | undefined {
@@ -314,7 +326,11 @@ function runReplacementAttempt(
314
326
  void waitForReplacementReady(child.pid, parentPid, expectedPort, { ...io, cancelled: () => settled }).then(
315
327
  // Never kill a live ordinary start at its valid reclaim boundary. Parent exit is the
316
328
  // final resource release the child may still be waiting for.
317
- ready => { settle({ kind: ready ? "ready" : "not-ready", pid: child.pid }); },
329
+ ready => {
330
+ settle(ready
331
+ ? { kind: "ready", pid: child.pid }
332
+ : { kind: "not-ready", pid: child.pid, terminate: () => terminateReplacement(child, io) });
333
+ },
318
334
  err => { settle({ error: err }); },
319
335
  );
320
336
  };
@@ -369,6 +385,11 @@ export async function spawnReplacementStart(
369
385
  continue;
370
386
  }
371
387
  if (outcome.kind === "not-ready") {
388
+ if (request.notReady === "terminate") {
389
+ const exited = await outcome.terminate();
390
+ log?.note(`replacement pid ${pid} did not answer within the readiness window; ${exited ? "stopped it" : "it did not exit after SIGKILL"}`);
391
+ throw handoffError(exited ? "not_ready" : "not_ready_unstopped");
392
+ }
372
393
  console.warn("Restart replacement is still starting after the readiness window; handing off to it anyway");
373
394
  log?.note(`replacement pid ${pid} is still starting after the readiness window; handing off anyway`);
374
395
  } else {
@@ -380,3 +401,30 @@ export async function spawnReplacementStart(
380
401
  log?.close();
381
402
  }
382
403
  }
404
+
405
+ /** Exit-observation budgets for {@link terminateReplacement}. */
406
+ export const REPLACEMENT_TERM_WAIT_MS = 3_000;
407
+ export const REPLACEMENT_KILL_WAIT_MS = 2_000;
408
+
409
+ function waitForChildExit(child: ChildProcess, ms: number, sleep: (ms: number) => Promise<void>): Promise<boolean> {
410
+ if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve(true);
411
+ return new Promise<boolean>(resolve => {
412
+ let done = false;
413
+ const finish = (exited: boolean) => { if (!done) { done = true; child.off("exit", onExit); resolve(exited); } };
414
+ const onExit = () => finish(true);
415
+ child.once("exit", onExit);
416
+ void sleep(ms).then(() => finish(child.exitCode !== null || child.signalCode !== null));
417
+ });
418
+ }
419
+
420
+ /**
421
+ * Stop a replacement that never answered: SIGTERM, wait for its exit, then SIGKILL and wait again.
422
+ * True only once the exit was observed; a kill call alone is not proof the port is free.
423
+ */
424
+ async function terminateReplacement(child: ChildProcess, io: ReplacementStartIo): Promise<boolean> {
425
+ const sleep = io.sleep ?? Bun.sleep;
426
+ try { child.kill("SIGTERM"); } catch { /* exited between the check and the signal */ }
427
+ if (await waitForChildExit(child, io.termWaitMs ?? REPLACEMENT_TERM_WAIT_MS, sleep)) return true;
428
+ try { child.kill("SIGKILL"); } catch { /* exited between the check and the signal */ }
429
+ return waitForChildExit(child, io.killWaitMs ?? REPLACEMENT_KILL_WAIT_MS, sleep);
430
+ }
@@ -96,6 +96,8 @@ export function createSseBlockBuffer(
96
96
  let buffer = "";
97
97
  let offset = 0;
98
98
  let scanOffset = 0;
99
+ let needsScan = false;
100
+ let lastCodeUnit = Number.NaN;
99
101
  let bufferBytes = 0;
100
102
  const delimiterPattern = /(?:\r\n|\r(?!\n)|\n){2}/g;
101
103
 
@@ -111,7 +113,7 @@ export function createSseBlockBuffer(
111
113
  if (!fragment) return;
112
114
  let fragmentBytes = Buffer.byteLength(fragment, "utf8");
113
115
  // Decoder output never splits a surrogate pair, but keep the helper exact for string callers.
114
- const last = buffer.charCodeAt(buffer.length - 1);
116
+ const last = lastCodeUnit;
115
117
  const first = fragment.charCodeAt(0);
116
118
  if (offset < buffer.length && last >= 0xd800 && last <= 0xdbff && first >= 0xdc00 && first <= 0xdfff) {
117
119
  fragmentBytes -= 2;
@@ -122,6 +124,8 @@ export function createSseBlockBuffer(
122
124
  try {
123
125
  compact();
124
126
  buffer += fragment;
127
+ lastCodeUnit = fragment.charCodeAt(fragment.length - 1);
128
+ needsScan ||= fragment.includes("\r") || fragment.includes("\n");
125
129
  reservation.commitRetained();
126
130
  budget.releaseRetained(bufferBytes, scope);
127
131
  bufferBytes = nextBytes;
@@ -131,11 +135,16 @@ export function createSseBlockBuffer(
131
135
  }
132
136
  },
133
137
  next() {
138
+ if (!needsScan) {
139
+ scanOffset = Math.max(offset, buffer.length - 3);
140
+ return null;
141
+ }
134
142
  delimiterPattern.lastIndex = scanOffset;
135
143
  const match = delimiterPattern.exec(buffer);
136
144
  if (!match) {
137
145
  // A delimiter is at most four code units. Revisit only its possible split prefix.
138
146
  scanOffset = Math.max(offset, buffer.length - 3);
147
+ needsScan = false;
139
148
  return null;
140
149
  }
141
150
  const start = match.index;
@@ -153,6 +162,8 @@ export function createSseBlockBuffer(
153
162
  buffer = "";
154
163
  offset = 0;
155
164
  scanOffset = 0;
165
+ needsScan = false;
166
+ lastCodeUnit = Number.NaN;
156
167
  }
157
168
  return { block, delimiter };
158
169
  },
@@ -164,6 +175,8 @@ export function createSseBlockBuffer(
164
175
  buffer = "";
165
176
  offset = 0;
166
177
  scanOffset = 0;
178
+ needsScan = false;
179
+ lastCodeUnit = Number.NaN;
167
180
  bufferBytes = 0;
168
181
  },
169
182
  };
@@ -12,6 +12,7 @@ import { sseDataPayload as parseSseBlock } from "./sse-payload-rewrite";
12
12
  import { safeResponseHeaders } from "./safe-response-headers";
13
13
  import type { AudioSocketTarget } from "./audio-dictation";
14
14
  import type { CodexWebSocketActivity } from "../codex/websocket-activity";
15
+ import { AGENT_ASSERTION_HEADER } from "../lib/authorization-scheme";
15
16
 
16
17
  export { safeResponseHeaders } from "./safe-response-headers";
17
18
 
@@ -131,6 +132,10 @@ export function selectForwardHeaders(
131
132
  if (codexOverride) {
132
133
  selected.set("authorization", `Bearer ${codexOverride.accessToken}`);
133
134
  selected.set("chatgpt-account-id", codexOverride.chatgptAccountId);
135
+ } else {
136
+ // Kept for the WS turn pipeline, which drops it again everywhere except first-party Direct.
137
+ const assertion = headers.get(AGENT_ASSERTION_HEADER);
138
+ if (assertion) selected.set(AGENT_ASSERTION_HEADER, assertion);
134
139
  }
135
140
  return selected;
136
141
  }
@@ -11,6 +11,7 @@ import { statusWinswRaw, type WinswStatus } from "../lib/winsw";
11
11
  import { withWindowsServiceMutationLock } from "../lib/windows-service-mutation-lock";
12
12
  import { maybeShowStarPrompt } from "../cli/star-prompt";
13
13
  import { serviceStatusReport } from "./diagnostics";
14
+ import { collectServiceStatusJson } from "./status-json";
14
15
  import { assertServiceAuthEnvironment } from "./auth-preflight";
15
16
  import { assertServiceEnvironmentMatchesInstall } from "./guards";
16
17
  import { resolveServiceListenPort } from "./definition";
@@ -47,10 +48,14 @@ export function normalizeServiceSubcommand(sub?: string): string {
47
48
  return sub ?? "install";
48
49
  }
49
50
 
51
+ const SERVICE_JSON_STATUS_ONLY = "--json applies to `ocx service status` only.";
52
+
50
53
  export interface ParsedServiceArgs {
51
54
  sub: string;
52
55
  backend: ServiceBackend | null;
53
56
  invalid: string[];
57
+ /** `--json`: accepted for `status` only; planServiceCommand rejects it elsewhere. */
58
+ json: boolean;
54
59
  }
55
60
 
56
61
  export type ServiceInstallationState = "installed" | "absent" | "unknown";
@@ -140,6 +145,9 @@ export function planServiceCommand(
140
145
  if (parsed.invalid.length > 0) {
141
146
  return { ok: false, message: `Unknown service option: ${parsed.invalid.join(" ")}` };
142
147
  }
148
+ if (parsed.json && parsed.sub !== "status") {
149
+ return { ok: false, message: SERVICE_JSON_STATUS_ONLY };
150
+ }
143
151
  if (parsed.backend && parsed.sub !== "install") {
144
152
  return { ok: false, message: "--native/--scheduler apply to `ocx service install` only; other subcommands use the installed backend." };
145
153
  }
@@ -174,9 +182,11 @@ export function planServiceCommand(
174
182
  export function parseServiceArgs(args: string[]): ParsedServiceArgs {
175
183
  let sub: string | undefined;
176
184
  let backend: ServiceBackend | null = null;
185
+ let json = false;
177
186
  const invalid: string[] = [];
178
187
  for (const arg of args) {
179
- if (arg === "--native") {
188
+ if (arg === "--json") json = true;
189
+ else if (arg === "--native") {
180
190
  if (backend === "scheduler") { invalid.push("--native (conflicts with --scheduler)"); continue; }
181
191
  backend = "native";
182
192
  }
@@ -188,7 +198,7 @@ export function parseServiceArgs(args: string[]): ParsedServiceArgs {
188
198
  else if (sub === undefined) sub = arg;
189
199
  else invalid.push(arg);
190
200
  }
191
- return { sub: normalizeServiceSubcommand(sub), backend, invalid };
201
+ return { sub: normalizeServiceSubcommand(sub), backend, invalid, json };
192
202
  }
193
203
 
194
204
  /** Remove the service credential only when no client connection can own it. */
@@ -243,6 +253,12 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise<v
243
253
  process.exitCode = 1;
244
254
  return;
245
255
  }
256
+ // Answered before platformOps() resolves: the JSON status reaches no install, start, stop,
257
+ // repair or migration path, and an unsupported platform still gets a document, not an exit.
258
+ if (command === "status" && parsed.json) {
259
+ console.log(JSON.stringify(await collectServiceStatusJson(), null, 2));
260
+ return;
261
+ }
246
262
  if (command === "repair" || command === "restart") {
247
263
  const verb: ServiceRepairVerb = command === "restart" ? "restart" : "repair";
248
264
  assertServiceEnvironmentMatchesInstall();
@@ -488,6 +504,13 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise<v
488
504
  };
489
505
 
490
506
  const preliminary = parseServiceArgs(filteredArgs);
507
+ // `claim` parses its own --json. Any other verb is refused here, before the Desktop check
508
+ // and the Windows mutation lock, so a misplaced --json never reaches either.
509
+ if (preliminary.json && preliminary.sub !== "status" && preliminary.sub !== "claim") {
510
+ console.error(SERVICE_JSON_STATUS_ONLY);
511
+ process.exitCode = 1;
512
+ return;
513
+ }
491
514
  if (preliminary.invalid.length === 0) {
492
515
  const refusal = desktopServiceCommandRefusal(preliminary.sub, undefined, supervisionLatch);
493
516
  if (refusal) {
@@ -7,7 +7,8 @@
7
7
  * route back through them (see `installedServiceListenPort` in `health.ts`, which
8
8
  * intentionally stays behind because it asks every backend).
9
9
  */
10
- import { loadConfig } from "../config";
10
+ import { getConfigDir, loadConfig } from "../config";
11
+ import { readClaudeInterceptProxyToken } from "../claude/intercept/proxy-auth";
11
12
  import { serviceApiTokenFilePath } from "../lib/service-secrets";
12
13
  import { PROXY_ENV_KEYS } from "../lib/proxy-env";
13
14
 
@@ -87,19 +88,30 @@ export function parseBakedListenPort(read: () => string): number | null {
87
88
  * own `applyProxyEnv` already treats both cases as equivalent. Only the canonical
88
89
  * upper-case name is baked, so a definition never carries two spellings of one setting.
89
90
  */
90
- export function resolvedProxyEnv(env: NodeJS.ProcessEnv = process.env): { name: string; value: string }[] {
91
+ export function resolvedProxyEnv(
92
+ env: NodeJS.ProcessEnv = process.env,
93
+ ownClaudeInterceptProxy?: string | null,
94
+ ): { name: string; value: string }[] {
95
+ const token = ownClaudeInterceptProxy === undefined ? readClaudeInterceptProxyToken(getConfigDir()) : null;
91
96
  const resolved: { name: string; value: string }[] = [];
92
97
  for (const key of PROXY_ENV_KEYS) {
93
- const value = [env[key]?.trim(), env[key.toLowerCase()]?.trim()].find(v => v && !OWN_CLAUDE_INTERCEPT_PROXY.test(v));
98
+ const value = [env[key]?.trim(), env[key.toLowerCase()]?.trim()].find(v => v && (
99
+ ownClaudeInterceptProxy === undefined ? !isOwnedClaudeInterceptProxy(v, token) : v !== ownClaudeInterceptProxy
100
+ ));
94
101
  if (value) resolved.push({ name: key, value });
95
102
  }
96
103
  return resolved;
97
104
  }
98
105
 
99
106
  /**
100
- * The URL Claude first-party writes into `~/.claude/settings.json` (`claudeInterceptProxyUrl`),
101
- * which every process Claude Code starts inherits. Baked into the service, it would send the
102
- * proxy's own outbound traffic back through its intercept listener. Only the token-bearing form
103
- * is matched: a plain `http://127.0.0.1:<port>` is a user's local proxy client and is kept.
107
+ * Match this installation's credential and exact URL components without creating a token.
108
+ * An inherited URL can still use an old bound port after the persisted config changes.
109
+ * Shape alone cannot establish ownership: a user proxy may also use an `opencodex` username.
104
110
  */
105
- const OWN_CLAUDE_INTERCEPT_PROXY = /^http:\/\/opencodex:[^@/]+@127\.0\.0\.1:\d{1,5}\/?$/i;
111
+ function isOwnedClaudeInterceptProxy(value: string, token: string | null): boolean {
112
+ if (!token) return false;
113
+ const prefix = `http://opencodex:${encodeURIComponent(token)}@127.0.0.1:`;
114
+ if (!value.startsWith(prefix)) return false;
115
+ const port = value.slice(prefix.length).replace(/\/$/, "");
116
+ return /^\d{1,5}$/.test(port) && Number(port) > 0 && Number(port) <= 65535;
117
+ }
@@ -282,16 +282,30 @@ export function diagnoseService(): ServiceDiagnostic {
282
282
  const enabled = installed && (() => { try { return sh(`systemctl --user is-enabled ${TASK}`) === "enabled"; } catch { return false; } })();
283
283
  const running = installed && (() => { try { return sh(`systemctl --user is-active ${TASK}`) === "active"; } catch { return false; } })();
284
284
  const stale = installed && bakedServicePathsDiagnostic() !== null;
285
- const viable = installed && enabled && running && !stale;
286
- const summary = !installed ? `not installed (${diagnostics})`
287
- : stale ? `installed, but stale (systemd user; ${diagnostics})`
288
- : viable ? `installed, enabled and running (systemd user; ${diagnostics})`
289
- : `installed, but ${!enabled ? "disabled" : "not running"} (systemd user; ${diagnostics})`;
290
- return { supported: true, installed, enabled, running, viable, startable: installed && !stale, stale, conflict: false, backend: "systemd", summary };
285
+ return deriveSystemdServiceDiagnostic({ installed, enabled, running, stale, diagnostics });
291
286
  }
292
287
  return { supported: false, installed: false, enabled: false, running: false, viable: false, startable: false, stale: false, conflict: false, backend: null, summary: `unsupported on ${process.platform}` };
293
288
  }
294
289
 
290
+ export interface SystemdServiceDiagnosticInputs {
291
+ installed: boolean;
292
+ enabled: boolean;
293
+ running: boolean;
294
+ stale: boolean;
295
+ diagnostics: string;
296
+ }
297
+
298
+ /** The systemd user-unit verdict, pure so its states are testable without a user bus. */
299
+ export function deriveSystemdServiceDiagnostic(inputs: SystemdServiceDiagnosticInputs): ServiceDiagnostic {
300
+ const { installed, enabled, running, stale, diagnostics } = inputs;
301
+ const viable = installed && enabled && running && !stale;
302
+ const summary = !installed ? `not installed (${diagnostics})`
303
+ : stale ? `installed, but stale (systemd user; ${diagnostics})`
304
+ : viable ? `installed, enabled and running (systemd user; ${diagnostics})`
305
+ : `installed, but ${!enabled ? "disabled" : "not running"} (systemd user; ${diagnostics})`;
306
+ return { supported: true, installed, enabled, running, viable, startable: installed && !stale, stale, conflict: false, backend: "systemd", summary };
307
+ }
308
+
295
309
  export function serviceStatusSummary(): string {
296
310
  return diagnoseService().summary;
297
311
  }