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
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
.personal-codex-accounts { display: grid; gap: 20px; padding: 8px 0 24px; color: var(--pw-text); }
.personal-codex-accounts > header { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 12px; }
.personal-codex-accounts h2 { margin: 0; font-size: var(--text-heading); line-height: var(--leading-heading); font-weight: 600; }
.personal-codex-accounts p { margin: 0; line-height: var(--leading-prose); }
.personal-codex-accounts header > span, .codex-account-effect, .personal-codex-accounts details, .codex-account-actions > span { color: var(--pw-muted); font-size: var(--text-caption); line-height: var(--leading-caption); }
.codex-account-enable { display: flex; align-items: center; gap: 12px; min-height: 44px; padding: 12px 0; border-top: 1px solid var(--pw-line); border-bottom: 1px solid var(--pw-line); }
.codex-account-enable input { accent-color: var(--pw-text); width: 18px; height: 18px; }
.codex-account-state { display: flex; gap: 8px; align-items: center; font-size: var(--text-body); }
.codex-account-list { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 24px; }
.codex-account-list article { display: flex; flex-direction: column; align-items: flex-start; gap: 16px; padding-top: 16px; border-top: 2px solid var(--pw-line); min-width: 0; }
.codex-account-list article.is-current { border-color: var(--pw-text); }
.codex-account-name { display: flex; align-items: baseline; flex-wrap: wrap; gap: 8px; }
.codex-account-name > span, .codex-account-list article > span { font-size: var(--text-caption); color: var(--pw-muted); }
.codex-account-list dl { display: grid; gap: 12px; margin: 0; }
.codex-account-list dt { color: var(--pw-muted); font-size: var(--text-caption); }
.codex-account-list dd { margin: 4px 0 0; font-size: var(--text-heading); font-variant-numeric: tabular-nums; }
.personal-codex-accounts button { display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 44px; padding: 8px 12px; border: 1px solid var(--pw-line-strong); border-radius: 6px; background: var(--pw-card); color: var(--pw-text); font-size: var(--text-body); cursor: pointer; }
.personal-codex-accounts button:disabled { opacity: .5; cursor: default; }
.personal-codex-accounts button:focus-visible, .personal-codex-accounts input:focus-visible, .personal-codex-accounts summary:focus-visible { outline: 2px solid var(--pw-blue); outline-offset: 3px; }
.codex-account-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; }
.personal-codex-accounts details { border-top: 1px solid var(--pw-line); padding-top: 16px; }
.personal-codex-accounts summary { cursor: pointer; padding: 4px 0; }
.personal-codex-accounts details p { margin: 12px 0; }
.personal-codex-accounts [role="alert"] { display: flex; gap: 8px; align-items: flex-start; color: var(--pw-red); }
@media (max-width: 640px) {
.codex-account-list { grid-template-columns: 1fr; gap: 20px; }
.codex-account-list dl { grid-template-columns: 1fr 1fr; width: 100%; }
.codex-account-list article > button { width: 100%; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import { useCallback, useEffect, useState } from "react";
import { RefreshCw, CheckCircle2, Circle, AlertCircle } from "lucide-react";
import { requestJson } from "../../data/chat";
import { useWorkspaceI18n } from "./i18n";
import "./codex-account-settings.css";

type AccountStatus = {
ok: boolean; available: boolean; code?: string | null; revision?: number;
active_slot?: string | null; selection_revision?: string; action?: string;
target?: string | null; recovery_required?: boolean; launch_failed?: boolean;
policy?: { enabled: boolean; slots: string[]; cooldown_seconds: number; restart_clients?: boolean };
slots?: string[]; last_checked_at?: number | null; last_switch_at?: number | null;
accounts?: Array<{ slot: string; availability: string; observed_at: number;
primary_used: number | null; secondary_used: number | null; reset_at: number | null;
credits_available: boolean | null }>;
};

const copy = {
"zh-CN": {
title: "Codex 原生账号", enabled: "自动接续已启用", disabled: "自动接续已关闭",
enable: "额度耗尽后自动切换", refresh: "检查额度", retry: "重新连接",
effect: "仅使用下方已登记账号。额度耗尽后正常退出 GPT 专用客户端,保存登录态并切换,再重新打开原来的界面。",
effectWait: "仅使用下方已登记账号。额度耗尽后等待客户端关闭,再保存登录态、切换账号并重新打开。",
restart: "允许自动退出并重开 GPT 客户端", restarting: "正在退出 GPT、切换账号并重新打开",
reopen: "重新打开 GPT", other_clients_busy: "还有其他进程占用 GPT 登录;关闭占用后检查额度重试",
client_quit_refused: "GPT 未接受正常退出;本次未换号,自动尝试已暂停", client_quit_timeout: "GPT 尚未退出;本次未换号,自动尝试已暂停",
client_changed: "GPT 进程已变化;已暂停本次切换", handoff_pending: "独立切换进程正在完成接续,请稍候",
handoff_interrupted: "接续进程中断;请检查并恢复本次切换", quota_stale: "额度记录已过期,正在重新检查",
current: "当前", next: "下一账号", slot: "账号", primary: "短周期剩余", secondary: "长周期剩余", unread: "未读取",
select: "切换到此账号", checking: "正在检查额度", ready: "当前账号可用", selected: "账号已切换",
waiting_for_idle: "已选好候选账号,等待 Codex 客户端关闭", selection_changed: "账号选择已变化,已暂停本次切换",
auth_required: "登录需要更新,请在原生 Codex 完成登录", quota_unknown: "暂时无法确认额度,将稍后重试",
cooldown: "切换后冷却中", all_unavailable: "暂时没有可用账号,等待额度恢复", target_unavailable: "所选账号当前不可用",
recovery_required: "上次切换未完成,需要恢复", unavailable: "原生账号暂不可用", adapter_changed: "槽位管理器已更新,请重新连接",
recover: "恢复未完成的切换", rollback: "撤销最近一次切换", available: "可用", exhausted: "额度已用完",
unknown: "额度未知", credits: "另有可用额度", help: "运行与恢复说明", last: "上次检查",
detail: "检测不会调用模型、购买额度或使用重置券。401 登录失效先由原生客户端刷新;其他账号可通过官方 CLI 单独刷新。查询失败不会被判为额度耗尽。自动退出仅针对 GPT 专用实例,拒绝退出或其他 CLI/文件占用会暂停尝试,不强杀进程。独立进程完成切换和重开,即使发起切换的 Codex 已退出。切换保留模型设置和会话历史,不重放已提交工作。自动观察随 LoopX 服务运行。",
extension_uninstalled: "尚未安装 Codex 账号扩展。安装 loopx-codex-provider-routing 后重新启动 LoopX 服务。",
adapter_unavailable: "未发现已安装的 Personal Apps 槽位管理器和 Codex CLI。",
native_unavailable: "无法连接原生槽位管理器,请检查本机安装后重试。",
launchFailed: "账号已切换,但界面未能重新打开。可以从原来的 GPT 入口打开;请勿重复切换。",
failure: "操作未完成。刷新状态后可以重试。", recoveryHint: "恢复会还原未完成事务;撤销会还原最近一次切换。两者都需要客户端关闭,且不会覆盖后续登录修改。",
},
en: {
title: "Native Codex accounts", enabled: "Automatic handoff enabled", disabled: "Automatic handoff off",
enable: "Switch automatically when quota runs out", refresh: "Check quota", retry: "Reconnect",
effect: "Use only the registered accounts below. After exhaustion, quit the dedicated GPT client normally, save the login, switch accounts, then reopen the same interface.",
effectWait: "Use only the registered accounts below. After exhaustion, wait for clients to close, save the login, switch accounts, then reopen the same interface.",
restart: "Allow automatic GPT client quit and restart", restarting: "Quitting GPT, switching accounts and reopening",
reopen: "Reopen GPT", other_clients_busy: "Another process still owns the GPT login; close it and check quota to retry",
client_quit_refused: "GPT refused normal quit; no account changed and automatic retries are paused", client_quit_timeout: "GPT has not exited; no account changed and automatic retries are paused",
client_changed: "The GPT process changed; this switch was paused", handoff_pending: "The independent handoff is still finishing; please wait",
handoff_interrupted: "The handoff process stopped; inspect and recover this switch", quota_stale: "Quota observations expired; checking again",
current: "Current", next: "Next account", slot: "Account", primary: "Short window left", secondary: "Long window left", unread: "Not read",
select: "Switch to this account", checking: "Checking quota", ready: "Current account is available", selected: "Account switched",
waiting_for_idle: "Candidate selected; waiting for Codex clients to close", selection_changed: "Account selection changed; this switch was paused",
auth_required: "Sign in again in native Codex", quota_unknown: "Quota could not be confirmed; retrying later",
cooldown: "Cooling down after switching", all_unavailable: "No account is available yet; waiting for quota to recover", target_unavailable: "Selected account is unavailable",
recovery_required: "An interrupted switch needs recovery", unavailable: "Native accounts unavailable", adapter_changed: "The slot manager changed; reconnect to it",
recover: "Recover interrupted switch", rollback: "Undo last switch", available: "Available", exhausted: "Quota exhausted",
unknown: "Quota unknown", credits: "Additional allowance available", help: "Operation and recovery", last: "Last checked",
detail: "Checks do not call a model, buy credits or use reset coupons. Native Codex refreshes an expired active login; other accounts can refresh separately through the official CLI. A failed check never means exhausted quota. Normal quit targets only the dedicated GPT instance. A refused quit or another CLI/file owner pauses attempts; processes are never force-killed. An independent process completes switching and reopening even when the calling Codex exits. Model settings and history stay intact; submitted work is never replayed. Observation runs with the LoopX service.",
extension_uninstalled: "Install the optional loopx-codex-provider-routing package, then restart the LoopX service.",
adapter_unavailable: "The installed Personal Apps slot manager and Codex CLI were not found.",
native_unavailable: "Cannot connect to the native slot manager. Check the local installation and retry.",
launchFailed: "The account switched, but the interface could not reopen. Open the original GPT entry; do not repeat the switch.",
failure: "The operation did not finish. Refresh its status before retrying.", recoveryHint: "Recovery restores an interrupted transaction; undo restores the last switch. Both require closed clients and refuse to overwrite later login changes.",
},
};

export function CodexAccountSettings() {
const { locale } = useWorkspaceI18n();
const c = copy[locale];
const [status, setStatus] = useState<AccountStatus | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState(false);
const endpoint = "/api/chat/codex-accounts";
const label = (key: string | null | undefined) => c[key as keyof typeof c] ?? c.unavailable;
const read = useCallback(async () => {
try {
const result = await requestJson<AccountStatus>(endpoint);
setStatus(result);
setError(false);
} catch { setError(true); }
}, []);
useEffect(() => {
void read();
const timer = window.setInterval(() => { if (!document.hidden) void read(); }, 5000);
return () => window.clearInterval(timer);
}, [read]);
async function act(operation: string, parameters = {}) {
setBusy(true);
setError(false);
try {
const result = await requestJson<AccountStatus>(endpoint, {
method: "POST", body: JSON.stringify({ operation, parameters }),
});
setStatus(result);
} catch { await read(); setError(true); }
finally { setBusy(false); }
}
const enabled = status?.policy?.enabled === true;
const restartClients = status?.policy?.restart_clients === true;
const problem = status?.code || (status?.ok === false ? "native_unavailable" : null);
return <section className="personal-codex-accounts" data-testid="codex-account-settings" aria-busy={busy}>
<header><h2>{c.title}</h2><span>{status?.active_slot ? `${c.current} ${status.active_slot.toUpperCase()} · ` : ""}{enabled ? c.enabled : c.disabled}</span></header>
{!status ? <p role="status">{c.checking}</p> : null}
{(problem && problem !== status?.action) || status?.available === false ? <p role="alert">{label(problem)}</p> : null}
{status?.available && status.slots?.length ? <>
<p className="codex-account-effect">{restartClients ? c.effect : c.effectWait}</p>
<label className="codex-account-enable"><input type="checkbox" checked={enabled} disabled={busy}
onChange={event => void act("configure", { expected_revision: status.revision, enabled: event.target.checked,
slots: status.slots })} /><span>{c.enable}</span></label>
<label className="codex-account-enable"><input type="checkbox" checked={restartClients} disabled={busy}
onChange={event => void act("configure", { expected_revision: status.revision, enabled,
restart_clients: event.target.checked, slots: status.slots })} /><span>{c.restart}</span></label>
<div className="codex-account-state" role="status" aria-live="polite">
{status.action === "ready" || status.action === "selected" ? <CheckCircle2 size={17} aria-hidden />
: <Circle size={17} aria-hidden />}
<span>{label(status.recovery_required ? "recovery_required" : status.action)}</span>
</div>
<div className="codex-account-list">
{status.slots.map(slot => {
const row = status.accounts?.find(a => a.slot === slot);
// Expired observations remain unknown in the UI, just as in policy.
const fresh = row && Date.now() / 1000 - row.observed_at <= 120;
const current = status.active_slot === slot;
const remaining = (used: number | null | undefined) => fresh && used != null ? `${100 - used}%` : "—";
return <article className={current ? "is-current" : ""} key={slot}>
<div className="codex-account-name"><strong>{c.slot} {slot.toUpperCase()}</strong>
<span>{current ? c.current : status.target === slot ? c.next : label(row?.availability === "auth_required" ? "auth_required" : fresh ? row?.availability : "unknown")}</span></div>
<dl><div><dt>{c.primary}</dt><dd>{remaining(row?.primary_used)}</dd></div>
<div><dt>{c.secondary}</dt><dd>{remaining(row?.secondary_used)}</dd></div></dl>
{fresh && row.credits_available ? <span>{c.credits}</span> : null}
{!fresh ? <span>{c.unread}</span> : null}
{!current ? <button type="button" disabled={busy || Boolean(status.recovery_required) || (!status.policy?.slots.includes(slot) && Boolean(status.policy?.slots.length))}
onClick={() => void act("switch", { manual_slot: slot, expected_selection: status.selection_revision,
operation_id: crypto.randomUUID() })}>{c.select}</button> : null}
</article>;
})}
</div>
{status.launch_failed ? <><p role="alert">{c.launchFailed}</p><button type="button" disabled={busy}
onClick={() => void act("reopen", { expected_selection: status.selection_revision })}>{c.reopen}</button></> : null}
<div className="codex-account-actions"><button type="button" disabled={busy}
onClick={() => void act("refresh")}><RefreshCw aria-hidden size={16} />{c.refresh}</button>
{status.last_checked_at ? <span>{c.last} {new Date(status.last_checked_at * 1000).toLocaleTimeString(locale)}</span> : null}
</div>
<details><summary>{c.help}</summary><p>{c.detail}</p><p>{c.recoveryHint}</p>
<div className="codex-account-actions"><button type="button" disabled={busy || !status.recovery_required}
onClick={() => void act("recover", { expected_revision: status.revision, expected_selection: status.selection_revision })}>{c.recover}</button>
<button type="button" disabled={busy || !status.last_switch_at}
onClick={() => void act("rollback", { expected_revision: status.revision, expected_selection: status.selection_revision })}>{c.rollback}</button></div>
</details>
</> : null}
{error ? <p role="alert"><AlertCircle aria-hidden size={16} />{c.failure}</p> : null}
{status?.code === "adapter_changed" || error ? <button type="button" disabled={busy} onClick={() => void act("configure", {
expected_revision: status?.revision, enabled, slots: status?.slots ?? status?.policy?.slots ?? [], reconnect: true,
restart_clients: restartClients,
})}>{c.retry}</button> : null}
</section>;
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { AutomationCadenceSettings } from "./automation-cadence-settings";
import { MachineConfigurationSettings } from "./machine-configuration-settings";
import { ConfigurationBackupSettings } from "./configuration-backup-settings";
import { OperatorCredentialSettings } from "./operator-credential-settings";
import { CodexAccountSettings } from "./codex-account-settings";
import type { PersonalWorkspaceCallbacks, WorkspaceGoal, WorkspaceGoalNotification } from "./personal-workspace-model";
import type { WorkspaceTheme } from "./workspace-theme";
import type { AttentionNotificationSupport } from "./attention-notifications";
Expand Down Expand Up @@ -227,6 +228,7 @@ export function WorkspaceSettingsPage({

{tab === "provider" ? (
<div className="personal-provider-settings">
<CodexAccountSettings />
<OperatorCredentialSettings />
</div>
) : null}
Expand Down
2 changes: 1 addition & 1 deletion loopx/chat_completed_todos.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ class CompletedTodoPages:
ttl_seconds = 300
max_cache_bytes = 16 * 1024 * 1024

def __init__(self):
def __init__(self) -> None:
self._snapshots = OrderedDict()
self._lock = Lock()

Expand Down
7 changes: 4 additions & 3 deletions loopx/chat_loopx_mode.py
Original file line number Diff line number Diff line change
Expand Up @@ -1138,7 +1138,8 @@ def pump_delegation_wakes(
class DelegationWakeService:
"""Cheap local pump hosted by the existing Chat server, beside the return service."""

def __init__(self, controller, *, goal_context=None, interval=WAKE_INTERVAL_SECONDS):
def __init__(self, controller: Any, *, goal_context: Callable[[dict[str, Any]], dict[str, Any]] | None = None,
interval: float = WAKE_INTERVAL_SECONDS) -> None:
self.controller = controller
self.goal_context = goal_context
self.interval = interval
Expand All @@ -1147,7 +1148,7 @@ def __init__(self, controller, *, goal_context=None, interval=WAKE_INTERVAL_SECO
target=self.run, daemon=True, name="loopx-delegation-wakes"
)

def start(self):
def start(self) -> DelegationWakeService:
self.thread.start()
return self

Expand All @@ -1163,6 +1164,6 @@ def run(self):
_LOG.warning("Delegation wake receipts unavailable; retrying")
self.stop.wait(self.interval)

def close(self):
def close(self) -> None:
self.stop.set()
self.thread.join(timeout=3)
2 changes: 1 addition & 1 deletion loopx/chat_operator_provider_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ class OperatorProviderRequestMixin:

server: _OperatorProviderServer

def _send_json(self, payload: dict, status: int = 200) -> None: ...
def _send_json(self, payload: dict, *, status: int = 200) -> None: ...

def _send_error(
self, message: str, *, status: int, error_code: str
Expand Down
Loading
Loading