diff --git a/docs/codex-config-proxy-plan.md b/docs/codex-config-proxy-plan.md new file mode 100644 index 0000000..d504b11 --- /dev/null +++ b/docs/codex-config-proxy-plan.md @@ -0,0 +1,158 @@ +# Codex Config And Proxy Plan + +## Goals + +- Launch Codex from Hermes without mutating the user's real `~/.codex`. +- Keep model/provider config isolated by Hermes profile and provider. +- Support OpenAI Responses providers directly. +- Add a local adapter for providers that only expose OpenAI Chat Completions. +- Keep Codex resume/history stable by using a consistent model provider id. + +## Directory Layout + +All generated Codex state should live under the Web UI home: + +```text +~/.hermes-web-ui/coding-agent/ + model/{profile}/{provider}/codex/ + config.toml + auth.json + AGENTS.md + workspace/{profile}/{provider}/ +``` + +Launch command shape: + +```bash +cd ~/.hermes-web-ui/coding-agent/workspace/{profile}/{provider} \ + && CODEX_HOME=~/.hermes-web-ui/coding-agent/model/{profile}/{provider}/codex \ + codex --model {model} +``` + +## Current MVP Config + +For Responses-compatible providers, generate: + +```toml +model_provider = "custom" +model = "provider-model-id" +disable_response_storage = true + +[model_providers.custom] +name = "provider-id" +base_url = "https://provider.example/v1" +wire_api = "responses" +requires_openai_auth = false +experimental_bearer_token = "provider-api-key" +``` + +Keep `auth.json` empty for third-party providers: + +```json +{} +``` + +Reason: avoid overwriting the user's official Codex / ChatGPT login cache. + +## Stable Provider Id + +Use `model_provider = "custom"` for third-party providers. + +Codex history and resume behavior can depend on provider identity. Keeping a stable provider id avoids making history appear to move between provider-specific ids. + +Provider identity remains visible in: + +- `[model_providers.custom].name` +- the generated directory path +- the UI launch result + +## Local Proxy Plan + +Some providers expose only OpenAI Chat Completions: + +```text +/v1/chat/completions +``` + +Codex prefers Responses: + +```text +/v1/responses +``` + +Add a local proxy endpoint: + +```text +/api/codex-proxy/{routeKey}/v1/responses +``` + +The `routeKey` should encode: + +```text +profile + "\0" + provider + "\0" + model +``` + +Authentication should use a generated `hwui_...` token, not the upstream provider key. + +## Responses To Chat Mapping + +When the upstream provider is Chat Completions only: + +- Convert Responses `input` items to Chat `messages`. +- Convert Responses `tools` to Chat `tools`. +- Convert `max_output_tokens` to `max_tokens`. +- Preserve `stream: true`. +- Map function calls and function outputs both ways. + +Response event mapping for streaming: + +```text +chat delta.content -> response.output_text.delta +chat delta.tool_calls -> response.function_call_arguments.delta +finish -> response.completed +``` + +Non-streaming mapping: + +```text +chat.choices[0].message.content -> output message/content +chat.choices[0].message.tool_calls -> output function_call items +chat.usage -> response.usage +``` + +## Config Generation With Proxy + +For Chat-only providers, generated Codex config should point at Hermes: + +```toml +model_provider = "custom" +model = "provider-model-id" +disable_response_storage = true + +[model_providers.custom] +name = "provider-id" +base_url = "http://127.0.0.1:{serverPort}/api/codex-proxy/{routeKey}/v1" +wire_api = "responses" +requires_openai_auth = false +experimental_bearer_token = "hwui_generated_route_token" +``` + +The proxy then forwards to the real provider with the real provider key. + +## Implementation Tasks + +1. Add a Codex proxy service parallel to the Claude Code proxy service. +2. Register route targets in memory for launch-time provider/model selection. +3. Add `/api/codex-proxy/:key/v1/responses`. +4. Implement Responses to Chat conversion. +5. Implement Chat to Responses conversion for streaming and non-streaming. +6. Add launch-time api mode selection for Codex providers. +7. Generate Codex `base_url` against the local proxy when api mode is Chat Completions. +8. Add server tests for config generation, auth rejection, streaming conversion, tool call conversion, and error passthrough. + +## Open Questions + +- Whether to expose Codex protocol selection in the UI immediately or infer it from provider preset metadata. +- Whether to persist proxy targets or require relaunch after server restart. +- Whether model catalog generation is needed for the first Codex MVP. +- Whether MCP and `AGENTS.md` should be copied from a template or edited only through the advanced config editor. diff --git a/packages/client/public/coding-agents/claude-code.svg b/packages/client/public/coding-agents/claude-code.svg new file mode 100644 index 0000000..b4a2131 --- /dev/null +++ b/packages/client/public/coding-agents/claude-code.svg @@ -0,0 +1,7 @@ + + Claude Code + + \ No newline at end of file diff --git a/packages/client/public/coding-agents/codex-openai.png b/packages/client/public/coding-agents/codex-openai.png new file mode 100644 index 0000000..44fc39c Binary files /dev/null and b/packages/client/public/coding-agents/codex-openai.png differ diff --git a/packages/client/src/api/coding-agents.ts b/packages/client/src/api/coding-agents.ts new file mode 100644 index 0000000..acb41a5 --- /dev/null +++ b/packages/client/src/api/coding-agents.ts @@ -0,0 +1,133 @@ +import { request } from './client' + +export type CodingAgentId = 'claude-code' | 'codex' +export type CodingAgentApiMode = 'chat_completions' | 'codex_responses' | 'anthropic_messages' +export type CodingAgentLaunchMode = 'scoped' | 'global' + +export interface CodingAgentToolStatus { + id: CodingAgentId + name: string + provider: string + command: string + packageName: string + installed: boolean + version: string + rawVersion: string + error?: string +} + +export interface CodingAgentsStatus { + tools: CodingAgentToolStatus[] +} + +export interface CodingAgentMutationResult extends CodingAgentsStatus { + success: boolean + tool: CodingAgentToolStatus + message?: string +} + +export interface CodingAgentConfigFileContent { + key: string + path: string + absolutePath: string + language: string + content: string + exists: boolean + size: number + profile: string + provider: string + rootDir: string +} + +export interface CodingAgentConfigScope { + profile?: string | null + provider?: string | null +} + +export interface CodingAgentLaunchRequest { + mode?: CodingAgentLaunchMode + profile?: string | null + provider?: string + model?: string + baseUrl?: string + apiKey?: string + apiMode?: CodingAgentApiMode +} + +export interface CodingAgentLaunchResult { + agentId: CodingAgentId + mode: CodingAgentLaunchMode + profile: string + provider: string + model: string + rootDir: string + workspaceDir: string + command: string + args: string[] + env: Record + shellCommand: string + files: Array<{ key: string; path: string; absolutePath: string }> +} + +export interface CodingAgentNativeLaunchResult extends CodingAgentLaunchResult { + nativeTerminal: true + terminal: string +} + +export async function fetchCodingAgentsStatus(): Promise { + return request('/api/coding-agents') +} + +export async function installCodingAgent(id: CodingAgentId): Promise { + return request(`/api/coding-agents/${id}/install`, { method: 'POST' }) +} + +export async function deleteCodingAgent(id: CodingAgentId): Promise { + return request(`/api/coding-agents/${id}`, { method: 'DELETE' }) +} + +export async function readCodingAgentConfigFile( + id: CodingAgentId, + key: string, + scope: CodingAgentConfigScope = {}, +): Promise { + const params = new URLSearchParams() + if (scope.profile) params.set('profile', scope.profile) + if (scope.provider) params.set('provider', scope.provider) + const query = params.toString() + return request( + `/api/coding-agents/${id}/config-files/${encodeURIComponent(key)}${query ? `?${query}` : ''}`, + ) +} + +export async function writeCodingAgentConfigFile( + id: CodingAgentId, + key: string, + content: string, + scope: CodingAgentConfigScope = {}, +): Promise { + return request(`/api/coding-agents/${id}/config-files/${encodeURIComponent(key)}`, { + method: 'PUT', + body: JSON.stringify({ content, profile: scope.profile, provider: scope.provider }), + }) +} + +export async function prepareCodingAgentLaunch( + id: CodingAgentId, + data: CodingAgentLaunchRequest, +): Promise { + return request(`/api/coding-agents/${id}/launch/prepare`, { + method: 'POST', + body: JSON.stringify(data), + }) +} + +export async function launchCodingAgentNativeTerminal( + id: CodingAgentId, + data: CodingAgentLaunchRequest, +): Promise { + return request(`/api/coding-agents/${id}/launch/native`, { + method: 'POST', + body: JSON.stringify(data), + }) +} diff --git a/packages/client/src/components/hermes/chat/TerminalPanel.vue b/packages/client/src/components/hermes/chat/TerminalPanel.vue index 9747c94..f2fdb80 100644 --- a/packages/client/src/components/hermes/chat/TerminalPanel.vue +++ b/packages/client/src/components/hermes/chat/TerminalPanel.vue @@ -12,7 +12,7 @@ import type { ITheme } from "@xterm/xterm"; const { t } = useI18n(); const message = useMessage(); -const props = defineProps<{ visible?: boolean }>(); +const props = defineProps<{ visible?: boolean; initialCommand?: string }>(); // ─── Terminal themes ──────────────────────────────────────────── @@ -106,6 +106,7 @@ const MAX_RECONNECT_ATTEMPTS = 3; let touchScrollLastY: number | null = null; let touchScrollRemainder = 0; const TOUCH_SCROLL_LINE_PX = 18; +const initialCommandSent = ref(false); // ─── Computed ────────────────────────────────────────────────── @@ -224,6 +225,7 @@ function handleControl(msg: any) { exited: false, }); switchSession(msg.id); + runInitialCommand(); break; case "exited": { @@ -251,6 +253,15 @@ function createSession() { send({ type: "create" }); } +function runInitialCommand() { + const command = props.initialCommand?.trim(); + if (!command || initialCommandSent.value) return; + initialCommandSent.value = true; + setTimeout(() => { + send(`${command}\r`); + }, 100); +} + function getOrCreateTerm(id: string): { term: Terminal; fitAddon: FitAddon } { let entry = termMap.get(id); if (!entry) { @@ -570,8 +581,11 @@ onUnmounted(() => { .terminal-panel-drawer { display: flex; height: 100%; + width: 100%; min-height: 0; + min-width: 0; position: relative; + overflow: hidden; } .sidebar-overlay { @@ -764,9 +778,11 @@ onUnmounted(() => { display: flex; align-items: center; justify-content: space-between; + gap: 10px; padding: 12px 16px; border-bottom: 1px solid $border-color; flex-shrink: 0; + min-width: 0; } .header-session-title { @@ -783,6 +799,7 @@ onUnmounted(() => { align-items: center; gap: 8px; flex-shrink: 0; + min-width: 0; } .theme-select { @@ -800,12 +817,15 @@ onUnmounted(() => { margin: 8px; overflow: hidden; min-height: 0; + min-width: 0; display: flex; flex-direction: column; } .terminal-xterm { flex: 1; + min-height: 0; + min-width: 0; border-radius: $radius-md; overflow: hidden; border: 1px solid $border-color; @@ -842,19 +862,46 @@ onUnmounted(() => { @media (max-width: $breakpoint-mobile) { .terminal-panel-drawer { - height: calc(100 * var(--vh)); - max-height: calc(100 * var(--vh)); + height: 100%; + max-height: 100%; } .terminal-main { min-height: 0; + min-width: 0; + } + + .terminal-header { + padding: 8px; + gap: 6px; + } + + .header-session-title { + display: none; + } + + .header-actions { + width: 100%; + justify-content: flex-end; + gap: 6px; + } + + .theme-select { + width: 96px; } .terminal-container { - margin-bottom: calc(8px + env(safe-area-inset-bottom, 0px)); + margin: 6px; + margin-bottom: calc(6px + env(safe-area-inset-bottom, 0px)); } .terminal-xterm { + border-radius: $radius-sm; + + :deep(.xterm) { + padding: 6px; + } + :deep(.xterm-viewport), :deep(.xterm-scrollable-element) { touch-action: pan-y; diff --git a/packages/client/src/components/layout/AppSidebar.vue b/packages/client/src/components/layout/AppSidebar.vue index 13e128a..27a8262 100644 --- a/packages/client/src/components/layout/AppSidebar.vue +++ b/packages/client/src/components/layout/AppSidebar.vue @@ -263,6 +263,14 @@ function openChangelog() {