@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.
- package/bin/ocx.mjs +37 -3
- package/gui/dist/assets/App-DMps4OLk.css +1 -0
- package/gui/dist/assets/{App-DgxkE6Md.js → App-sUf_8afJ.js} +18 -18
- package/gui/dist/assets/{Tray-DbTNc_88.js → Tray-BSsxznDm.js} +1 -1
- package/gui/dist/assets/index-BlFE_VB_.js +94 -0
- package/gui/dist/assets/quota-summary-BRlbXgw0.js +1 -0
- package/gui/dist/index.html +1 -1
- package/gui/dist/provider-icons/workbuddy-color.svg +29 -0
- package/package.json +4 -3
- package/src/adapters/anthropic.ts +2 -5
- package/src/adapters/claude-cli/usage-admission.ts +14 -0
- package/src/adapters/google-vertex-endpoint.ts +9 -1
- package/src/adapters/google-vertex-key-project.ts +223 -0
- package/src/adapters/google.ts +13 -3
- package/src/adapters/openai-chat/tool-name-registry.ts +3 -1
- package/src/adapters/openai-chat.ts +2 -2
- package/src/adapters/openai-responses/passthrough.ts +2 -1
- package/src/adapters/openai-responses/request-strips.ts +92 -0
- package/src/adapters/openai-responses/transport.ts +6 -0
- package/src/adapters/registry.ts +5 -0
- package/src/adapters/workbuddy-schema.ts +61 -0
- package/src/adapters/workbuddy.ts +68 -0
- package/src/bridge/response-json.ts +1 -1
- package/src/bridge/sse.ts +1 -1
- package/src/chat/inbound.ts +20 -2
- package/src/chat/outbound.ts +47 -8
- package/src/chat/reasoning-details.ts +137 -0
- package/src/cli/capabilities-agents-routing.ts +53 -3
- package/src/cli/capabilities-base.ts +15 -2
- package/src/cli/capabilities-observe-system.ts +4 -4
- package/src/cli/codex-cli-update.ts +8 -0
- package/src/cli/codex-shim-autorestore.ts +5 -0
- package/src/cli/combo-result.ts +6 -0
- package/src/cli/combo-stats.ts +3 -0
- package/src/cli/combo.ts +66 -42
- package/src/cli/dispatch.ts +30 -0
- package/src/cli/fallback.ts +127 -0
- package/src/cli/help.ts +1 -0
- package/src/cli/index.ts +1 -2
- package/src/cli/launcher-context.ts +49 -2
- package/src/cli/link-apply-restart.ts +72 -0
- package/src/cli/link-child.ts +6 -2
- package/src/cli/link-cleanup.ts +95 -0
- package/src/cli/link-enrollment.ts +4 -0
- package/src/cli/link.ts +123 -35
- package/src/cli/provider.ts +46 -3
- package/src/cli/registry.ts +13 -1
- package/src/cli/restart-failure.ts +4 -0
- package/src/cli/runtime-api.ts +1 -1
- package/src/cli/status-codex-settings.ts +56 -0
- package/src/cli/status-fallback.ts +29 -0
- package/src/cli/status-install.ts +52 -0
- package/src/cli/status.ts +20 -1
- package/src/cli/system-restart-client.ts +32 -1
- package/src/cli/update-restart-child.ts +4 -3
- package/src/cli/update-restart-home.ts +20 -8
- package/src/cli/usage-compare.ts +68 -12
- package/src/client/connect.ts +82 -6
- package/src/client/home-unreachable.ts +16 -0
- package/src/client/hub-relay.ts +8 -2
- package/src/client/link-home.ts +119 -6
- package/src/client/link-ingress.ts +1 -1
- package/src/client/link-parity-reporter.ts +107 -0
- package/src/client/link-readiness.ts +97 -0
- package/src/client/link-relay-transport.ts +84 -9
- package/src/client/link-relay.ts +98 -15
- package/src/client/link-tunnel.ts +45 -18
- package/src/client/machine-listener.ts +103 -3
- package/src/client/runtime-restart.ts +127 -0
- package/src/client/runtime.ts +155 -25
- package/src/codex/account-store.ts +17 -3
- package/src/codex/app-server-restart-service.ts +6 -5
- package/src/codex/auth-context.ts +10 -6
- package/src/codex/catalog/effort.ts +9 -2
- package/src/codex/catalog/gather-capture.ts +4 -2
- package/src/codex/catalog/parsing.ts +4 -0
- package/src/codex/catalog/provider-models.ts +14 -8
- package/src/codex/chatgpt-refresh-failure.ts +6 -2
- package/src/codex/cli-install-provenance.ts +418 -0
- package/src/codex/cli-installation-targets.ts +5 -1
- package/src/codex/cli-update-plan.ts +43 -16
- package/src/codex/desktop-app/picker-freshness.ts +10 -8
- package/src/codex/desktop-compatibility/usage-activation.ts +2 -1
- package/src/codex/desktop-compatibility/usage-controller.ts +7 -1
- package/src/codex/desktop-compatibility/windows-key-protection.ts +18 -6
- package/src/codex/pool-refresh-backoff.ts +3 -1
- package/src/codex/quota-history.ts +7 -0
- package/src/codex/quota.ts +10 -0
- package/src/codex/subagent-model-fallback.ts +24 -1
- package/src/codex/user-identity.ts +35 -6
- package/src/codex/windows-current-sid.ts +162 -0
- package/src/combos/index.ts +1 -0
- package/src/combos/jev-quota-activation.ts +5 -1
- package/src/combos/jev-quota-route.ts +1 -1
- package/src/combos/jev-quota.ts +9 -1
- package/src/combos/jev-session-pin-config.ts +51 -0
- package/src/combos/jev.ts +6 -0
- package/src/combos/resolve.ts +12 -0
- package/src/combos/types.ts +19 -0
- package/src/config/diagnostics.ts +5 -0
- package/src/config/load-degrade.ts +2 -0
- package/src/config/provider-validation.ts +7 -0
- package/src/config/schema/config-schema.ts +3 -0
- package/src/config/schema/fallback-policy.ts +40 -0
- package/src/config/schema/leaf-validators.ts +1 -0
- package/src/generated/compatibility-version.json +375 -195
- package/src/lib/authorization-scheme.ts +95 -0
- package/src/lib/decision-quota-sink.ts +10 -0
- package/src/lib/provider-client-headers.ts +1 -0
- package/src/lib/redact.ts +2 -2
- package/src/lib/system-restart-contract.ts +24 -0
- package/src/lib/test-home-guard.ts +78 -11
- package/src/lib/translator-budget.ts +4 -4
- package/src/lib/upstream-retry.ts +4 -1
- package/src/link/child-parity.ts +124 -0
- package/src/link/codex-settings.ts +37 -0
- package/src/link/compatibility.ts +119 -0
- package/src/link/diagnostics.ts +193 -0
- package/src/link/management-routes.ts +2 -0
- package/src/link/peer-report.ts +100 -0
- package/src/link/relay-auth.ts +6 -0
- package/src/link/remote-platform.ts +78 -0
- package/src/link/ssh-argv.ts +19 -0
- package/src/link/status-projection.ts +63 -2
- package/src/link/supervisor.ts +45 -12
- package/src/link/tunnel-history.ts +109 -0
- package/src/link/tunnel-state.ts +80 -17
- package/src/messaging/remote-files.ts +24 -1
- package/src/oauth/generic-account-failover.ts +91 -7
- package/src/oauth/index.ts +16 -1
- package/src/oauth/kimi.ts +2 -0
- package/src/oauth/model-discovery-auth.ts +5 -0
- package/src/oauth/nous.ts +4 -1
- package/src/oauth/store.ts +6 -2
- package/src/oauth/token-guardian.ts +7 -0
- package/src/oauth/types.ts +9 -0
- package/src/oauth/workbuddy.ts +139 -0
- package/src/protocols/codecs/chat.ts +3 -3
- package/src/protocols/encoders/adapter-events.ts +26 -2
- package/src/protocols/encoders/chat.ts +55 -3
- package/src/providers/antigravity-quota-identity.ts +37 -0
- package/src/providers/base-url-choices.ts +8 -5
- package/src/providers/derive.ts +23 -3
- package/src/providers/mistral-transport.ts +37 -4
- package/src/providers/model-discovery.ts +2 -0
- package/src/providers/model-rename-fields.ts +1 -0
- package/src/providers/quota/account-cache.ts +3 -2
- package/src/providers/quota/antigravity.ts +27 -22
- package/src/providers/quota/report-cache.ts +13 -4
- package/src/providers/quota/vendor-probes-key.ts +60 -0
- package/src/providers/quota/workbuddy.ts +83 -0
- package/src/providers/quota-decision-antigravity.ts +64 -0
- package/src/providers/quota-decision-publication.ts +11 -0
- package/src/providers/quota-decision-snapshot.ts +21 -4
- package/src/providers/quota-types.ts +2 -0
- package/src/providers/quota.ts +3 -1
- package/src/providers/registry/entries-core.ts +9 -0
- package/src/providers/registry/entries-extended.ts +20 -7
- package/src/providers/registry/model-ids.ts +3 -0
- package/src/providers/registry/model-seeds.ts +27 -28
- package/src/providers/registry/types.ts +19 -1
- package/src/providers/resolved-model-policy.ts +4 -4
- package/src/providers/routed-config.ts +15 -0
- package/src/providers/stale-model-roster-migration.ts +83 -15
- package/src/providers/workbuddy-credit-balance.ts +81 -0
- package/src/providers/workbuddy-models.ts +37 -0
- package/src/providers/workbuddy.ts +60 -0
- package/src/quota-pool/assemble.ts +326 -0
- package/src/quota-pool/contract.ts +58 -0
- package/src/quota-pool/identity.ts +203 -0
- package/src/remote/features.ts +74 -0
- package/src/remote/protocol.ts +2 -0
- package/src/remote/served-by.ts +57 -0
- package/src/responses/parser.ts +7 -0
- package/src/responses/reasoning-envelope.ts +12 -0
- package/src/responses/reasoning-replay-cache.ts +18 -14
- package/src/router.ts +2 -0
- package/src/routing/analytics.ts +2 -3
- package/src/routing/fallback-policy.ts +290 -0
- package/src/routing/fallback-state.ts +179 -0
- package/src/server/audio-client.ts +2 -1
- package/src/server/audio-dictation.ts +2 -1
- package/src/server/audio-upstream.ts +2 -1
- package/src/server/auth-cors.ts +7 -5
- package/src/server/background-lifecycle.ts +4 -0
- package/src/server/chat-completions.ts +22 -2
- package/src/server/chat-native-reasoning.ts +10 -3
- package/src/server/chat-native.ts +13 -2
- package/src/server/claude-messages.ts +3 -0
- package/src/server/gui-session.ts +2 -1
- package/src/server/index/link-listener.ts +11 -1
- package/src/server/index/link-management.ts +47 -0
- package/src/server/index/link-relay-sessions.ts +20 -7
- package/src/server/index/optional-listeners.ts +11 -2
- package/src/server/index/serve-options.ts +19 -2
- package/src/server/live.ts +5 -0
- package/src/server/local-management-read-client.ts +3 -1
- package/src/server/management/combo-routes.ts +10 -4
- package/src/server/management/context.ts +1 -1
- package/src/server/management/fallback-routes.ts +75 -0
- package/src/server/management/link-enrollment.ts +21 -3
- package/src/server/management/link-route-state.ts +2 -0
- package/src/server/management/link-routes.ts +16 -2
- package/src/server/management/provider-routes.ts +1 -1
- package/src/server/management/quota-pool-routes.ts +24 -0
- package/src/server/management/route-registry.ts +3 -0
- package/src/server/management-api.ts +16 -0
- package/src/server/management-auth.ts +2 -1
- package/src/server/port-reclaim.ts +51 -9
- package/src/server/request-log-conversation.ts +49 -0
- package/src/server/request-log.ts +2 -0
- package/src/server/responses/agent-task-recovery.ts +1 -1
- package/src/server/responses/anthropic-thinking-recovery.ts +1 -0
- package/src/server/responses/compact.ts +12 -4
- package/src/server/responses/core-auth.ts +11 -1
- package/src/server/responses/core-combo.ts +125 -32
- package/src/server/responses/core-lifetime.ts +2 -2
- package/src/server/responses/core-normalize.ts +6 -3
- package/src/server/responses/core-opaque-recovery.ts +1 -0
- package/src/server/responses/core-options.ts +5 -0
- package/src/server/responses/core-replay.ts +2 -1
- package/src/server/responses/encrypted-payload.ts +3 -3
- package/src/server/responses/jev-combo-choice.ts +39 -0
- package/src/server/responses/jev-session-pin.ts +203 -0
- package/src/server/responses/main-hard-lock-routing.ts +54 -2
- package/src/server/responses/passthrough-delivery.ts +4 -0
- package/src/server/responses/policy-fallback.ts +44 -1
- package/src/server/responses/request-prepare.ts +19 -6
- package/src/server/responses/request-transport.ts +25 -6
- package/src/server/responses/run-turn-execution.ts +2 -4
- package/src/server/responses/sidecar-execution.ts +2 -4
- package/src/server/responses-phase-inference.ts +267 -0
- package/src/server/restart-replacement.ts +50 -2
- package/src/server/sse-payload-rewrite.ts +14 -1
- package/src/server/ws-bridge.ts +5 -0
- package/src/service/cli.ts +25 -2
- package/src/service/definition.ts +20 -8
- package/src/service/diagnostics.ts +20 -6
- package/src/service/status-json.ts +116 -0
- package/src/types/config.ts +13 -0
- package/src/types/fallback.ts +38 -0
- package/src/types/provider.ts +9 -1
- package/src/types/request.ts +1 -0
- package/src/types/wire.ts +1 -0
- package/src/update/codex-cli-update-launch-policy.d.mts +9 -0
- package/src/update/codex-cli-update-launch-policy.mjs +30 -0
- package/src/usage/jev-stats.ts +54 -14
- package/gui/dist/assets/App-dnkJ85te.css +0 -1
- package/gui/dist/assets/index-TmaHa54u.js +0 -94
- 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"
|
|
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 => {
|
|
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 =
|
|
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
|
};
|
package/src/server/ws-bridge.ts
CHANGED
|
@@ -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
|
}
|
package/src/service/cli.ts
CHANGED
|
@@ -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 === "--
|
|
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(
|
|
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 &&
|
|
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
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|