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
58 changes: 58 additions & 0 deletions apps/desktop/loopx-control-plane/src-tauri/src/services.rs
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,13 @@ fn connect_owned_service(
}
let mut command = Command::new(&runtime.executable);
configure_runtime_environment(&mut command);
if matches!(kind, ServiceKind::Chat) {
configure_codex_chat_home(
&mut command,
env::var_os("HOME"),
env::var_os("LOOPX_CHAT_CODEX_HOME"),
);
}
command
.args(kind.command_args_on_port(port))
.stdin(Stdio::null())
Expand Down Expand Up @@ -403,6 +410,13 @@ fn connect_service(

let mut command = Command::new(executable);
configure_runtime_environment(&mut command);
if matches!(kind, ServiceKind::Chat) {
configure_codex_chat_home(
&mut command,
env::var_os("HOME"),
env::var_os("LOOPX_CHAT_CODEX_HOME"),
);
}
command
.args(kind.command_args())
.stdin(Stdio::null())
Expand Down Expand Up @@ -767,6 +781,21 @@ pub(crate) fn configure_runtime_environment(command: &mut Command) {
);
}

// A desktop Chat service has its own account binding. An unrelated terminal's
// CODEX_HOME must not pair the selected GPT model with another provider. CLI
// launches retain their environment semantics; explicit Chat overrides win.
fn configure_codex_chat_home(
command: &mut Command,
home: Option<std::ffi::OsString>,
chat_home: Option<std::ffi::OsString>,
) {
if let Some(selected) = chat_home.filter(|value| !value.is_empty()) {
command.env("LOOPX_CHAT_CODEX_HOME", selected);
} else if let Some(home) = home.filter(|value| !value.is_empty()) {
command.env("LOOPX_CHAT_CODEX_HOME", PathBuf::from(home).join(".codex"));
}
}

fn runtime_search_path(
home: Option<std::ffi::OsString>,
inherited: Option<std::ffi::OsString>,
Expand Down Expand Up @@ -1143,6 +1172,35 @@ fn classify_response(
#[cfg(test)]
mod tests {
use super::*;

#[test]
fn desktop_chat_account_default_and_explicit_override_are_independent_of_caller_home() {
let user_home = PathBuf::from("synthetic-user-home");
for chat_home in [None, Some(std::ffi::OsString::new())] {
let mut command = Command::new("synthetic-loopx");
command.env("CODEX_HOME", "different-provider-home");
configure_codex_chat_home(&mut command, Some(user_home.clone().into()), chat_home);
assert!(command
.get_envs()
.any(|(key, value)| key == "LOOPX_CHAT_CODEX_HOME"
&& value == Some(user_home.join(".codex").as_os_str())));
assert!(command.get_envs().any(|(key, value)| key == "CODEX_HOME"
&& value == Some(std::ffi::OsStr::new("different-provider-home"))));
}
let mut command = Command::new("synthetic-loopx");
configure_codex_chat_home(
&mut command,
Some(user_home.into()),
Some("selected-chat-home".into()),
);
assert!(command
.get_envs()
.any(|(key, value)| key == "LOOPX_CHAT_CODEX_HOME"
&& value == Some(std::ffi::OsStr::new("selected-chat-home"))));
let mut command = Command::new("synthetic-loopx");
configure_codex_chat_home(&mut command, None, None);
assert_eq!(command.get_envs().count(), 0);
}
#[test]
fn package_identity_requires_core_installation_qualification() {
let identity = serde_json::json!({"schema_version":"loopx_runtime_identity_v1",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,17 @@
import assert from "node:assert/strict";

import { goalReplanCadenceEditorValue, goalReplanCadenceWriteValue, hasReplanCadenceOverride, goalStorageEditorValue, parseEditableCapabilityJson, projectEditableCapabilityConfiguration, replanCadenceEditorDefaults } from "../src/data/capability-configuration.js";
import { changeStewardExecutorDraft, goalReplanCadenceEditorValue, goalReplanCadenceWriteValue, hasReplanCadenceOverride, goalStorageEditorValue, parseEditableCapabilityJson, projectEditableCapabilityConfiguration, replanCadenceEditorDefaults } from "../src/data/capability-configuration.js";

const codexDraft = { executor_endpoint: "codex", executor_model: "gpt-fixture", executor_reasoning_effort: "high", selection_policy: "preferred" };
const managedDraft = changeStewardExecutorDraft(codexDraft, "executor_endpoint", "dsh");
assert.deepEqual(managedDraft, { ...codexDraft, executor_endpoint: "dsh", executor_model: null, executor_reasoning_effort: null },
"switching Runtime lets the owning backend resolve the new provider defaults");
assert.deepEqual(changeStewardExecutorDraft({ ...managedDraft, executor_model: "deepseek-fixture" }, "executor_endpoint", "codex"),
{ ...codexDraft, executor_model: null, executor_reasoning_effort: null }, "switching back cannot send a DeepSeek model to Codex");
assert.deepEqual(changeStewardExecutorDraft(codexDraft, "executor_endpoint", "codex"), codexDraft,
"reselecting the same Runtime preserves explicit model choices");
assert.deepEqual(changeStewardExecutorDraft(codexDraft, "executor_model", "custom-model"), { ...codexDraft, executor_model: "custom-model" });
assert.equal(codexDraft.executor_model, "gpt-fixture", "editing the draft does not mutate the accepted configuration");

const periodicReportEditor = {
fields: [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,16 @@ function configurationObject(value: unknown): Record<string, unknown> {
: {};
}

/** A saved model override belongs to the Runtime that was selected with it. */
export function changeStewardExecutorDraft(
current: Record<string, unknown>, key: string, value: unknown,
): Record<string, unknown> {
const changed = { ...current, [key]: value };
return key === "executor_endpoint" && value !== current.executor_endpoint
? { ...changed, executor_model: null, executor_reasoning_effort: null }
: changed;
}

/**
* Project a public capability value onto the editor-owned write surface.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ function ConfigurationFieldControl({ copy, field, id, onChange, value, timezone
const { locale } = useWorkspaceI18n();
const label = copy[field.key]?.label ?? field.label;
const readOnly = !onChange;
const runtimeDefault = locale === "zh-CN" ? "使用所选 Runtime 的默认值" : "Use the selected Runtime's default";
const directionLabel = (direction: string) => locale === "zh-CN"
? direction === "forward" ? "正向" : "反向"
: direction === "forward" ? "Forward" : "Reverse";
Expand Down Expand Up @@ -66,7 +67,7 @@ function ConfigurationFieldControl({ copy, field, id, onChange, value, timezone
<label htmlFor={id}>
<span>{label}</span>
<select disabled={readOnly} id={id} onChange={onChange ? (event) => onChange(field.key, event.target.value) : undefined} value={typeof value === "string" ? value : ""}>
<option value="">{copy[field.key]?.options?.[""] ?? ""}</option>
<option value="">{copy[field.key]?.options?.[""] ?? (field.key === "executor_reasoning_effort" ? runtimeDefault : "")}</option>
{(field.options ?? []).map((option) => <option key={option} value={option}>{copy[field.key]?.options?.[option] ?? (field.key === "review_order" ? directionLabel(option) : option)}</option>)}
</select>
</label>
Expand Down Expand Up @@ -96,6 +97,7 @@ function ConfigurationFieldControl({ copy, field, id, onChange, value, timezone
max={field.maximum}
min={field.minimum}
onChange={onChange ? (event) => onChange(field.key, numeric ? Number(event.target.value) : event.target.value) : undefined}
placeholder={field.key === "executor_model" ? runtimeDefault : undefined}
readOnly={readOnly}
required={field.required}
type={numeric ? "number" : "text"}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import {
type MachineConfigurationRollbackPlan,
type MachineConfigurationTransaction,
} from "../../data/chat";
import { goalStorageEditorValue, projectEditableCapabilityConfiguration, replanCadenceEditorValue } from "../../data/capability-configuration";
import { changeStewardExecutorDraft, goalStorageEditorValue, projectEditableCapabilityConfiguration, replanCadenceEditorValue } from "../../data/capability-configuration";
import { CapabilityConfigurationFields } from "./capability-configuration-fields";
import { withReportScheduleTimezone } from "./periodic-report-schedule-field";
import { localizeCapability, localizedCapabilityFieldCopy } from "./capability-localization";
Expand Down Expand Up @@ -210,7 +210,9 @@ export function MachineConfigurationSettings({ section, onChanged }: { section:

function changeDraft(key: string, value: unknown) {
setDraft((current) => selected?.capability_id === "periodic_report"
? withReportScheduleTimezone(current, key, value) : { ...current, [key]: value });
? withReportScheduleTimezone(current, key, value)
: selected?.capability_id === "steward_executor"
? changeStewardExecutorDraft(current, key, value) : { ...current, [key]: value });
setPreview(null);
setPreviewOperation("upsert");
setError(null);
Expand Down
14 changes: 14 additions & 0 deletions docs/architecture/rfcs/capable-manager-semantic-handoff-v0.md
Original file line number Diff line number Diff line change
Expand Up @@ -808,6 +808,20 @@ Target an ingress receipt within two seconds on a healthy local service, indepen

Use existing service recovery and receipt pumps. No manager-specific business automation for each kind of request. Expose configuration and failures through the existing CLI, capability settings and manager conversation. Troubleshooting distinguishes model failure, tool/policy denial, state conflict, unreachable receiver and transport formatting/delivery failure.

**Provider configuration rejection:** a structured Responses
`invalid_request_error` remains a rejected request when its JSON envelope omits
HTTP status; conflicting or malformed status does not establish that category.
Chat explains the model/account/provider check without publishing upstream prose
or replaying the request. Desktop-owned Chat defaults to the standard Codex home
unless its dedicated Chat override is explicit, so an unrelated terminal's
provider configuration cannot replace the default account. CLI and LaunchAgent
home precedence, original Session identity, explicit Runtime selection and
provider permissions remain with their existing owners. Model discovery reads
the same native home as execution. Changing the executor in the guided settings
clears the previous model/effort overrides for the existing backend to resolve;
preview and apply still own the persisted change. This bounded repair
does not qualify sustained operation or every provider/model combination.

**Turn-bound steering (A23):** preserve the requested Session, exact Turn,
unchanged text and ingress identity through browser persistence, dispatch and
replay. The current frontend requires persistent retry readback before sending;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -654,6 +654,16 @@ M0 盘点真实字段和 producer;以下是迁移验收底线,不代表已

使用现有服务恢复和 receipt pump,不为每类请求创建管家业务 automation。配置/故障通过已有 CLI、capability settings、管家对话展示。诊断区分模型失败、工具/策略拒绝、状态冲突、接收方不可达、格式/传输失败。

**Provider 配置拒绝:** 结构化 Responses `invalid_request_error` 的 JSON 包装即使
省略 HTTP 状态,仍应识别为请求拒绝;冲突或格式错误的状态不能建立该分类。
Chat 提示核对模型、账号和 provider,不发布上游原文,也不自动重放请求。
桌面自己启动的 Chat 默认使用标准 Codex 目录,除非显式指定专用 Chat override,
避免无关终端的 provider 配置替换默认账号。CLI 与 LaunchAgent 的目录优先级、
原 Session 身份、显式 Runtime 选择及 provider 权限仍归已有 owner。
模型发现与执行读取同一原生目录。引导设置中切换执行器会清空原模型/档位覆盖,
由已有 backend 解析默认值;持久变更仍须通过原来的预览与应用。
这一有界修复不代表持续运行或所有 provider/model 组合已资格化。

**回合绑定的 steering(A23):** 浏览器保存、派发和重放全过程保持请求指定的
Session、精确 Turn、不变文本与 ingress 身份。当前前端发送前要求持久重试信息
回读成功,存储不可用时保留可编辑草稿。投递未知时沿原目标重试,确认未送达则
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
20 changes: 20 additions & 0 deletions docs/integrations/session-runtime-control-plane-adapter.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,26 @@ Resume and submit reject a different home before starting an upstream process
or modifying a turn. Legacy sessions acquire the binding only after successful
upstream restoration; startup alone neither rebinds nor copies their history.

Desktop-owned Chat processes explicitly default `LOOPX_CHAT_CODEX_HOME` to
`~/.codex`, independently of the launching terminal's `CODEX_HOME`. An explicit
`LOOPX_CHAT_CODEX_HOME` still selects another native account configuration.
Direct CLI launches and existing LaunchAgents retain their documented
precedence. Switching the service home requires a new conversation; existing
threads remain bound to their original store. The steward defaults to Codex/GPT;
DeepSeek Harness remains an explicit Runtime choice with its own SDK and
operator credential, rather than sending GPT model names to a DeepSeek provider.
Model discovery uses the same captured native home as execution. The guided
steward editor clears model/effort overrides when changing executor, and labels
empty values as Runtime defaults; the existing preview/apply owns the write.

中文:桌面自己启动的 Chat 默认固定到 `~/.codex`,不再继承启动终端的
`CODEX_HOME`;显式 `LOOPX_CHAT_CODEX_HOME` 仍可选择其它原生账号配置。
直接 CLI 和已有 LaunchAgent 的优先级不变。换目录需要新对话,旧线程保持原存储绑定。
管家默认 Codex/GPT;DeepSeek Harness 是可选 Runtime,单独检查 SDK 与 operator
凭据,不把 GPT 模型名发给 DeepSeek provider。
模型发现与执行共用启动时固定的原生目录。引导表单切换执行器会清空模型与档位覆盖,
空值明确表示 Runtime 默认值;写入仍归原有预览/应用流程。

The macOS LaunchAgent installer preserves an existing binding on upgrade,
including older shell-export plists. Use `LOOPX_CHAT_CODEX_HOME` explicitly when
installing a deliberately different host profile. A later ambient `CODEX_HOME`
Expand Down
12 changes: 7 additions & 5 deletions loopx/chat_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -204,17 +204,19 @@ def _retry_error_details(error: Any) -> dict[str, Any]:
def _terminal_turn_error(error: Any, fallback: str) -> CodexChatAgentError:
"""Project only the app-server's typed error, never its arbitrary prose."""
info = error.get("codexErrorInfo") if isinstance(error, dict) else None
# Some app-server versions wrap an HTTP error as JSON in `message` while
# reporting codexErrorInfo=other. Only the structured HTTP status/type is
# used here; the nested message may contain private request details.
# Some Responses providers return a structured error without an HTTP
# status. The typed error still identifies a rejected request; arbitrary
# nested prose may contain private request details and is never projected.
if info == "other" and isinstance(error.get("message"), str):
try:
upstream = json.loads(error["message"])
except (TypeError, ValueError):
upstream = None
if (
isinstance(upstream, dict)
and upstream.get("status") == 400
and ("status" not in upstream or (
type(upstream["status"]) is int and upstream["status"] == 400
))
and isinstance(upstream.get("error"), dict)
and upstream["error"].get("type") == "invalid_request_error"
):
Expand All @@ -224,7 +226,7 @@ def _terminal_turn_error(error: Any, fallback: str) -> CodexChatAgentError:
error_code="upstream_invalid_request",
gate=_host_tool_gate(
summary,
"检查管家选择的模型、Codex CLI 与当前账户是否兼容,再重试。",
"在设置中核对所选模型与 Codex 账户、服务商配置是否匹配,再重试;本次请求不会自动重放。",
),
)
# App-server v2 exposes camel-case discriminators. Unknown/new variants
Expand Down
5 changes: 3 additions & 2 deletions loopx/chat_providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,15 @@
_PRIVATE_PARTS = {".git", ".env", ".ssh", ".aws", ".config", "node_modules"}


def codex_model_options(codex_bin: str) -> dict[str, Any] | None:
"""Project the local Codex catalog without exposing its instructions."""
def codex_model_options(codex_bin: str, *, codex_home: Path | None = None) -> dict[str, Any] | None:
"""Read the selected account's catalog without exposing its instructions."""
executable = codex_bin if Path(codex_bin).is_absolute() else shutil.which(codex_bin)
if not executable:
return None
try:
result = subprocess.run(
[str(executable), "debug", "models"],
env={**os.environ, **({"CODEX_HOME": str(codex_home)} if codex_home is not None else {})},
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
text=True, encoding="utf-8", errors="replace", timeout=15, check=False,
)
Expand Down
2 changes: 1 addition & 1 deletion loopx/chat_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -404,7 +404,7 @@ def inspect_agent_model_options(self, agent_id: str, *, work_dir: Path) -> dict[
if capability is None or not capability.get("available"):
raise agent_endpoint_error(agent_id, reason=str((capability or {}).get("unavailable_reason") or ""))
if agent_id == "codex":
return {"agent_id": agent_id, "model": codex_model_options(self.codex_bin), "supported": True}
return {"agent_id": agent_id, "model": codex_model_options(self.codex_bin, codex_home=self.codex_home), "supported": True}
if agent_id == "claude-code":
return {
"agent_id": agent_id,
Expand Down
Loading
Loading