Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions devlog/_plan/261002_claude_ux/010_roadmap.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# 261002 Claude UX — roadmap (010)

Three outcomes, four work-phases (wp1 = this roadmap).

| wp | Unit | Doc | Branch |
| --- | --- | --- | --- |
| wp2 | Start the Claude intercept pair on demand instead of asking for a restart | 020 | `codex/claude-intercept-on-demand` |
| wp3 | Top-level Claude page under Codex: Account / Code / Desktop / Settings | 030 | `codex/gui-claude-page` |
| wp4 | Local OpenCodex.app rebuild from merged dev, install handed to the user | 040 | none |

## Evidence

- `startClaudeIntercept` (src/claude/intercept/runtime.ts) runs once, from `createClaudeInterceptLifecycle().start` in
src/server/index/optional-listeners.ts at `startServer`. It resolves `null` when Claude routing is off
(`claudeCode.enabled === false`), the intercept is disabled, the role is `client`, or the public port is ephemeral, and a
bind failure only warns. Nothing starts it later, so `getClaudeInterceptState()` stays `null` for the life of the process.
- The CLI first-party toggle (`PUT /api/claude-code {cliFirstParty}`, agent-settings-routes.ts:1557) then refuses with
`intercept_unavailable` ("…restart needed"), and the Desktop panel shows `claudeDesktop.firstParty.proxyStopped`:
"로컬 프록시 127.0.0.1:{port}가 실행 중이 아님 — OpenCodex 재시작". `claude.firstParty.disabled` also tells the user to restart.
The live user config has `claudeCode.enabled: false`, `desktopMode: first-party`, which reproduces it.
- The Desktop picker controller and runtime are created inside `startClaudeIntercept` only, so the picker is also absent.

## Order

wp2 and wp3 are independent and run in parallel lanes (separate worktrees). wp3 rebases after wp2 merges if both touch
ClaudeCode/ClaudeDesktop copy. wp4 needs both merged.

31 changes: 31 additions & 0 deletions devlog/_plan/261002_claude_ux/020_intercept_on_demand.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# 020 Intercept on demand (wp2)

## Behaviour

- A serialized, idempotent `ensureClaudeIntercept()` starts the intercept pair (CONNECT proxy, TLS listener, CLI catalog hook,
Desktop picker runtime/controller when wired) in the running process using the persisted config, reusing the options the
lifecycle got at `startServer` (dispatch, ports, picker routes). A second call while one is in flight awaits the same start;
a call when already bound returns the bound state.
- Result is a discriminated outcome: `{ok:true,state}` or `{ok:false, reason}` with reason
`disabled` (Claude routing off or `claudeCode.intercept.enabled=false`), `client_role`, `ephemeral_port`,
`port_in_use` (with the port), `failed` (message without secrets). No reason means "restart".
- Callers: the `cliFirstParty` toggle and every Desktop first-party/picker apply route call it before refusing; turning Claude
routing on (`PUT /api/native-integrations/claude {enabled:true}`) calls it; a new `POST /api/claude-intercept/start`
(dashboard session) lets the GUI retry explicitly. Status payloads gain `interceptReason` so the GUI can say why.
- Turning routing off does not tear the pair down (existing relay-native behaviour stays).
- The startup path keeps its synchronous contract (AGENTS.md: no await between `Bun.serve` and lab activation); the lifecycle
only records its options and a starter, it does not change the startup order.

## GUI

- Replace every "restart OpenCodex" instruction for the intercept (ko/en/... `claudeDesktop.firstParty.proxyStopped`,
`claude.firstParty.disabled`, `claude.firstParty.refusal.interceptUnavailable`) with the concrete reason plus a
"Start" action that calls the new endpoint, and a port hint for `port_in_use`
(`claudeCode.intercept.port`). All locales updated.

## Tests

Lifecycle: ensure starts when startup returned null, is idempotent and serialized, maps EADDRINUSE to `port_in_use`.
Route: `cliFirstParty:true` with no bound intercept starts it and succeeds; disabled routing returns `disabled`.
Desktop status reports `interceptReason`. File-size: keep agent-settings-routes.ts under 2000 lines (move to a sibling).

26 changes: 26 additions & 0 deletions devlog/_plan/261002_claude_ux/030_claude_page.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# 030 Claude page (wp3)

## IA

Sidebar: Dashboard, Codex, **Claude** (new, directly under Codex), Providers, … Integrations keeps its other clients; the Claude
tab inside Integrations is removed and its hashes redirect: `#integrations/claude` → `#claude/code`,
`#integrations/claude/desktop` → `#claude/desktop`.

Page shaped like Codex Set (page-tabs, hash-routed, panels mounted on first visit and kept mounted while inactive, keyboard arrows):

| Tab | Hash | Content |
| --- | --- | --- |
| Account | `#claude` / `#claude/account` | Anthropic accounts: the existing provider auth + AnthropicAccountPoolSettings (pool on/off, auto-switch threshold), reset grants |
| Code | `#claude/code` | existing ClaudeCode page (CLI first-party, models, sidecars) |
| Desktop | `#claude/desktop` | existing ClaudeDesktop page (mode, picker, bindings) |
| Settings | `#claude/settings` | Claude routing on/off, intercept status/port and start action (from wp2 when merged), compatibility mode, inject agents, auto-context — only settings that exist today, no new behaviour |

## Design read

Dashboard tool surface (D4, variance 3, motion 1): reuse existing page-tabs, panels and tokens; no new visual language.
One primary action per tab. Empty/disabled states explain why and link to the action.

## Checks

lint:gui, build:gui, GUI tests for tab routing/redirects, i18n completeness (all locales), screenshots of each tab in light/dark
for the PR and a UI review by an inherited-model subagent.
6 changes: 6 additions & 0 deletions devlog/_plan/261002_claude_ux/040_app_rebuild.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# 040 Local app rebuild (wp4)

From the merged dev head: `bun install`, `(cd gui && bun install && bun run build)`, `cd desktop && bun run prepare-sidecar && bun run prepare-widget && bun run build:local`.
Output: `desktop/src-tauri/target/release/bundle/macos/OpenCodex.app` and dmg. Do not replace /Applications/OpenCodex.app or
restart the running proxy; hand the path to the user to install. Unsigned local build: Gatekeeper may need right-click → Open.

24 changes: 24 additions & 0 deletions devlog/_plan/261002_claude_ux/050_audit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# 050 Audit fold (wp1)

Independent inherited-model reviewer: NEAR-PASS. Folded into 020/030 as binding requirements:

1. `ensureClaudeIntercept` is a method of `ClaudeInterceptLifecycle` (claude-intercept-lifecycle.ts), so a later start goes
through the same dispatch wrapper (`listener ??= requestServer`) and `ownsListener` classifies it as the intercept ingress;
expose it through `OptionalListenerSet` and the existing `linkListener: () => optionalListeners` seam (index.ts gains no lines).
2. One `inflight` promise; `stopped` flag refuses ensure after stop; `stop()` awaits the in-flight start; ensure awaits a
pending startup start first and only starts a new pair if it resolved to null.
3. Record the last start outcome: pure precheck (disabled / client role / ephemeral port) plus bind error mapping
(EADDRINUSE on the CONNECT proxy → `port_in_use` with port). Picker proxy bind failure stays non-fatal and is reported as
`pickerReason`.
4. Port mismatch (bound port ≠ configured) gets reason `port_mismatch`: rebind is out of scope; the copy explains the bound port
is in use for this run and how to change it, never "restart".
5. Use startServer's live config object; recompute `observeClaudeDesktopMode` on each ensure.
6. Security: ensure (POST /api/claude-intercept/start and the triggering PUTs) is refused on the hub-management ingress and for
data-plane API keys; same gate everywhere; tests prove it.
7. GUI routing list for wp3: app-routing.ts (Page union, page list, `#claude` redirect inverted), App.tsx title map + NAV,
`nav.claude` and all copy in all locales, INTEGRATION_TAB_HASHES old hashes kept as redirects, integration-tabs.ts:34,
overview-clients.ts:318/367, api-surface-cards.tsx:20, Integrations.tsx:20, Claude.tsx:10-11.

Notes kept: agent-settings-routes.ts sibling move up front (1943/2000); a later start may run picker CA drain/keychain → GUI
pending state; routing off keeps the pair bound (relay-native).

4 changes: 2 additions & 2 deletions docs-site/src/content/docs/guides/claude-code.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ native `claude` binary instead, so the command stays useful with routing off:
| Where routing is off | What happens |
| --- | --- |
| `claudeCode.enabled: false` in config | Native launch, with a notice that routing is disabled |
| The running proxy reports `enabled: false` from `GET /api/claude-code` | Native launch, with a notice to restart the service after re-enabling |
| The running proxy reports `enabled: false` from `GET /api/claude-code` | Native launch; re-enabling starts interception on demand |
| `claudeCode.enabled` absent or `true` | Routed through the proxy, unchanged |

Only an explicit `false` triggers the fallback, so a proxy predating the field stays routed. A
Expand Down Expand Up @@ -298,7 +298,7 @@ next request; Desktop does not need a restart.

Turn on the CLI switch in Claude → Code, or run `ocx claude config set --first-party on`; use `off` to disable it. The switch is immediate and refuses `{enabled:false, cliFirstParty:true}` before any field is saved; it may also refuse to turn on if the local intercept is unavailable, the CA cannot be prepared, settings cannot be read, or a foreign proxy setting owns the keys. Off persists even when the intercept is unavailable; disabling Claude routing alone leaves an owned settings env untouched. For fully native terminal traffic with only Desktop first-party on, set `NO_PROXY='*'` in the shell. This still carries the first-party account risk stated above.
Disabling Claude routing leaves the owned settings env untouched. While the bound listener still runs, every Messages request relays unchanged; after it stops, plain `claude` cannot connect until OpenCodex runs or Desktop/CLI first-party is turned off. Native `ocx claude` sets `NO_PROXY=*` only for an owned env without a foreign inherited `HTTPS_PROXY`/`https_proxy`. With a foreign proxy it preserves that value and warns that the settings-owned intercept still applies; turn Desktop/CLI first-party off or unset the setting.
The UI distinguishes uncertainty about whether settings still point at its proxy (unknown), a token-bearing opencodex proxy with a foreign CA (foreign: fix HTTPS_PROXY / NODE_EXTRA_CA_CERTS manually), and a tokenless loopback proxy beside a foreign CA (local: ownership is unconfirmed; remove HTTPS_PROXY if unused). With matching applied settings and a bound listener but Claude routing off, disabled means requests relay unchanged until restart; turn first-party off to remove settings. An owned URL with no listener is stopped; a bound listener with an owned CA but mismatched port or token is broken even when routing is off. With an intent on, stopped or broken plus ineligible interception displays routingOff: Claude routing or the intercept is off, or this machine is a client of another opencodex hub; enable interception on this machine or turn first-party off to remove the settings. Only when interception is eligible does stopped advise starting opencodex and broken advise `ocx ensure` or restart. CLI intent with no proxy is not applied; one intent with a live proxy gets the shared-relay notice; any remaining proxy with neither intent is residual, unless unknown, foreign, or local takes precedence.
The UI distinguishes uncertainty about whether settings still point at its proxy (unknown), a token-bearing opencodex proxy with a foreign CA (foreign: fix HTTPS_PROXY / NODE_EXTRA_CA_CERTS manually), and a tokenless loopback proxy beside a foreign CA (local: ownership is unconfirmed; remove HTTPS_PROXY if unused). With matching applied settings and a bound listener but Claude routing off, disabled means requests relay unchanged while it is bound; turn first-party off to remove settings. An owned URL with no listener is stopped; a bound listener with an owned CA but mismatched port or token is broken even when routing is off. With an intent on, stopped or broken plus ineligible interception displays routingOff: Claude routing or the intercept is off, or this machine is a client of another opencodex hub; enable interception on this machine or turn first-party off to remove the settings. When interception is stopped, use **Start interception** or `ocx claude intercept start` to retry within the running OpenCodex service. Saving first-party settings also starts it automatically. A refusal reports disabled routing, client role, an ephemeral public port, an occupied port, or a startup failure. For an occupied port, free it or set `claudeCode.intercept.port`. If a pair is already bound on a different port, the response names both ports; use the bound port or restore the configured value. CLI intent with no proxy is not applied; one intent with a live proxy gets the shared-relay notice; any remaining proxy with neither intent is residual, unless unknown, foreign, or local takes precedence.

- Gateway discovery (`/model` → "From gateway") is not used, because Claude Code only queries
`GET /v1/models` on a configured gateway. Routed models reach the picker another way; see
Expand Down
14 changes: 11 additions & 3 deletions gui/src/components/ClaudeDesktopPicker.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import ClaudeInterceptStart from "./ClaudeInterceptStart";
import { interceptReasonKey } from "../pages/claude-code-first-party";
import { useState } from "react";
import { useI18n, type TKey } from "../i18n/shared";
import { readJsonOrThrow } from "../fetch-json";
Expand Down Expand Up @@ -72,8 +74,12 @@ export default function ClaudeDesktopPicker({
apiBase,
picker,
onUpdated,
pickerReason,
pickerFailurePort,
}: {
apiBase: string;
pickerReason?: string | null;
pickerFailurePort?: number;
picker: DesktopPickerStatus;
onUpdated?: (picker: DesktopPickerStatus) => void;
}) {
Expand All @@ -93,7 +99,7 @@ export default function ClaudeDesktopPicker({
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled: !current.desired, persist: true }),
});
let payload: { ok?: boolean; picker?: DesktopPickerStatus } | undefined;
let payload: { ok?: boolean; reason?: string; port?: number; picker?: DesktopPickerStatus } | undefined;
if (response.ok) {
payload = await readJsonOrThrow<{ ok?: boolean; picker?: DesktopPickerStatus }>(
response,
Expand All @@ -103,11 +109,11 @@ export default function ClaudeDesktopPicker({
// A refused enable is a useful status response (not a transport failure); 503
// carries the controller's reason so the card can explain the missing proxy.
try {
payload = await response.json() as { ok?: boolean; picker?: DesktopPickerStatus };
payload = await response.json() as { ok?: boolean; reason?: string; port?: number; picker?: DesktopPickerStatus };
} catch {
throw new Error(t("claudeDesktop.updateFailed"));
}
if (!payload.picker) throw new Error(t("claudeDesktop.updateFailed"));
if (!payload.picker) throw new Error(t(interceptReasonKey(payload.reason), { port: payload.port ?? "" }));
}
if (!payload || payload.ok !== true && !payload.picker || !payload.picker || !isDesktopPickerStatus(payload.picker)) {
throw new Error(t("claudeDesktop.updateFailed"));
Expand All @@ -123,6 +129,8 @@ export default function ClaudeDesktopPicker({

return (
<section className="claude-picker" aria-labelledby="claude-picker-title">
{!current.listenerReady && pickerReason && <span role="status">{t(interceptReasonKey(pickerReason === "port_in_use" && !pickerFailurePort ? "failed" : pickerReason), { port: pickerFailurePort ?? "" })}</span>}
{!current.listenerReady && !pickerReason && <ClaudeInterceptStart apiBase={apiBase} onStarted={() => { setLocalPicker(null); onUpdated?.(current); }} />}
<div className="claude-picker-header">
<div className="claude-picker-copy">
<h3 id="claude-picker-title" className="claude-picker-title">{t("claudeDesktop.picker.title")}</h3>
Expand Down
31 changes: 31 additions & 0 deletions gui/src/components/ClaudeInterceptStart.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { useState } from "react";
import { useT } from "../i18n/shared";
import { interceptReasonKey } from "../pages/claude-code-first-party";

const FAILED_REASON = "failed";

export default function ClaudeInterceptStart({ apiBase, reason, port, onStarted }: {
apiBase: string; reason?: string | null; port?: number; onStarted: () => void;
}) {
const t = useT();
const [pending, setPending] = useState(false);
const [failure, setFailure] = useState<{ reason: string; port?: number } | null>(null);
const start = async () => {
if (pending) return;
setPending(true);
setFailure(null);
try {
const response = await fetch(`${apiBase}/api/claude-intercept/start`, { method: "POST" });
const result = await response.json() as { ok?: boolean; reason?: string; port?: number };
if (!response.ok || !result.ok) setFailure({ reason: result.reason ?? FAILED_REASON, port: result.port });
else onStarted();
} catch { setFailure({ reason: FAILED_REASON }); }
finally { setPending(false); }
};
return <span role="status">
{t(interceptReasonKey(failure?.reason ?? reason), { port: failure?.port ?? port ?? "" })}{" "}
<button type="button" className="btn btn-ghost" disabled={pending} onClick={() => void start()}>
{t(pending ? "claude.intercept.starting" : "claude.intercept.start")}
</button>
</span>;
}
Loading
Loading