Author SHA1 Message Date
host 8b2686548c fix: serialize concurrent PG schema init with advisory lock
ci / test (push) Successful in 23m44s
Startup race: concurrent init_db (cloud-server + worker) ran
CREATE TABLE / CREATE INDEX IF NOT EXISTS against a DB where the
object did not exist yet; the check-then-create is not atomic, so
both sessions created it and one died with UniqueViolation on
pg_type_typname_nsp_index (tables) or pg_class_relname_nsp_index
(indexes). Hit on the 37ba687 and 7983567 deploys (7983567 added
idx_tasks_parent); systemd restart healed it every time.

Fix (advisory lock, not catch-duplicate_object): init_postgres_schema
takes pg_advisory_xact_lock (key 20261108, released at its commit),
and server.init_db takes the session-level lock for the whole PG
branch so the _ensure_* objects outside POSTGRES_SCHEMA
(task_continue_messages, user_gateway_keys, task_attempts,
task_audit_loop_state) are covered too. Same idiom as the existing
RATE_LIMIT_SWEEP_ADVISORY_KEY sweep lock; key is distinct.

Tests: two new real-PG regression tests (skipped without
CLOUD_TEST_PG_DSN), 4 processes racing on an empty schema, for
init_postgres_schema and for full init_db; both fail on the
unfixed code and pass with the fix.
2026-11-08 05:49:52 +00:00
host 7983567491 feat: side-thread task kind (Muse role alignment phase 2)
ci / test (push) Successful in 25m2s
Tasks gain kind='main'|'side' (NULL=main). A side thread hangs under a
parent task, defaults to the local executor, never builds a worktree,
branch or container unless explicitly asked, and writes its conclusion
back to the parent via task_followups on terminal transition.

- server: kind validation + side-thread gates (parent required, no
  coordinator_parts/upload_id/environment_id, parent must not be a
  coordinator), local executor default, GET /tasks kind filter,
  /worktree 404 and /fork /merge /pr 409 for side threads.
- worker: side-default shunt in _run_attempt (no-worktree scratch cwd,
  in-memory plan=1 when a repo is present, seed/checkpoint skipped),
  _writeback_side_thread, marker task-create kind field + inheritance,
  side threads excluded from marker spawn counts.
- host/sandbox: --no-worktree plumbing for dispatch/goal/popen
  (Local + Docker), scratch cwd work/side-threads/<task-id>.
- console (classic only): kind filter + [旁聊] rows + '开旁聊' entry,
  side-thread dispatch mode, side-thread list in detail, merge/PR/fork
  hidden for side threads; build exit 0, console.html sha df39db5a.
- tests: new test_sidethread.py (29 tests) + kind backfill tests in
  test_p2_migrations.py; full cloud suite 3900 tests OK (skipped=13).

Design: verification/codex-muse-role-alignment-design-20261108.md
Summary: verification/codex-muse-role-align-p1p2-20261108.md
Local only: not pushed, not merged, not deployed.
2026-11-07 20:51:23 +00:00
host f6df76ee8f feat: unify coordinator semantics (Muse role alignment phase 1)
Platform coordinator.py is the single source of truth; the skill
coordinator template (templates/roles/00-coordinator.md + SKILL.md
appendix 00 + body) now derives from it instead of describing a
second, model-played coordinator (U1-U13).

- server: _create_task accepts parent_task_id (404/400 checks,
  coordinator-under-coordinator rejected); follow-ups to a running
  coordinator return 409 at entry (_create_followup /
  _deliver_to_thread) with documented alternatives (U14/U15).
- tests: 8 new coordinator tests (depth limit, lineage, replay,
  follow-up 409); appendix sync/provenance/transplant suites green.
- check_appendix.py: 12 chapters match templates/roles/.
2026-11-07 19:43:03 +00:00
admin 834eced686 Merge pull request 'Docker Netfix B' (#2) from docker-netfix-b into main
ci / test (push) Successful in 31m47s
2026-11-06 23:51:04 +01:00
host 4d3ea048a5 docker: pin tenant networks to 172.16.0.0/16 via explicit --subnet
ci / test (push) Successful in 35m1s
Tenant networks were created bare (docker network create <name>),
letting Docker's default pool hand out subnets outside the iptables
8889 allowlist (172.16.0.0/12). The smoke tenant landed on
192.168.32.0/20, so containers never reached the egress proxy and
every model turn timed out at 600s twice.

Fix (B): derive a deterministic /24 inside 172.16.0.0/16 from
sha256(network name) with linear probing on IPAM overlap, max 32
probes. 172.16.0.0/16 is the one /16 in the allowlist Docker never
auto-assigns (default pool starts at 172.17.0.0/16). No persistent
state; capacity 256 tenant networks per host, explicit failure when
exhausted. Pre-existing out-of-range networks are reused as-is and
need manual removal (see README).

Tests: 7 new (candidate properties incl. wrap-around, pinned argv,
overlap probe, exhaustion, empty name).
2026-11-06 22:00:52 +00:00
admin d5642c6252 Merge pull request 'feat: wire native Codex UI as /console entry, keep classic console at /console-classic' (#1) from feat/codex-native-ui into main
ci / test (push) Successful in 37m47s
2026-11-06 21:19:18 +01:00
host 3e0ff898e0 feat: wire native Codex UI as /console entry, keep classic console at /console-classic
ci / test (push) Successful in 45m25s
- Vendor codex-ui-stack host/ as cloud/codex-ui-host/ (original/ not vendored, 650MB)
- Bridge emulates 7 methods missing in codex 0.164.0-alpha.9, exposes 29
  experimental-protocol methods (150 -> 179), Linux binary candidates,
  read-only POST /__codex_git
- GET /console 302s to the UI host (:8095) when reachable, else falls back
  to the classic React console; GET /console-classic always serves it
- Alignment tables: docs/codex-native-ui-alignment.md
- Tests: test_console 7, new test_codex_ui_host 4, handler method pin 247 -> 249
2026-11-06 19:03:35 +00:00
host 41f3985248 console: unify goal mode to no-time-limit only (port of 395dffb onto 37ba687)
ci / test (push) Successful in 40m37s
DispatchSection keeps a single 'goal 模式' checkbox (the former
invincible option): checking it sends goal_mode=1 plus
no_time_limit=1. TaskDetailPanel drops the '无敌版' label.
Ported verbatim from feat-console-goal-unify commit 395dffb
(base 622c51e); console.html rebuilt from cloud/console-react
with npm run build:console (tsc+vite pass) and synced verbatim
from dist/index.html (sha256 daba1941...).
2026-11-06 17:09:34 +00:00
50 changed files with 4977 additions and 194 deletions

No files matched your search

+6 -1
View File
@@ -734,7 +734,12 @@ interrupted 且终态任务 cancel 返回 400、心跳后 sweep 不扫刚心跳
非租户模式完全一致(`/app/work`、codex-home、repos/<项目>、
test-repo),只是宿主挂载源按项目分开——容器内看不到其它
项目的任何文件;同时每个项目进独立的 Docker 网络
`codex-<项目>`(容器跨项目网络隔离),容器统一加
`codex-<项目>`(容器跨项目网络隔离),建网时显式
`--subnet` 把子网固定在 172.16.0.0/16 内(/24 按网络名
确定性划段),保证落在宿主 iptables 的代理放行范围
(172.16.0.0/12)内;2026-11-07 之前建好的范围外旧网
(如 codex-smoke-20261107)不会自动纠正,须人工删除后
重建。容器统一加
`--cap-drop=ALL --security-opt=no-new-privileges`。codex-home
只播种模板的 config.toml(relay 端点与 provider 名,不含
密钥值),绝不复制 thread_history。注意:内层 codex 沙箱仍
@@ -0,0 +1,370 @@
/**
* Bridge to the real local `codex app-server`.
*
* The desktop app talks to the Codex app server over stdio JSON-RPC; the main
* process owns that child process. In a browser we cannot spawn anything, so
* this module owns the child on the Node side and exposes a plain
* request/response surface over HTTP.
*
* The binary ships inside the ChatGPT desktop app:
* ChatGPT.app/Contents/Resources/codex-cli/CodexCLI.app/Contents/MacOS/codex
*
* On Linux (this repo's server target) the same binary is found via
* $CODEX_BIN (same env name host.py uses), /opt/hatch-image/bin/codex, or
* PATH. $CODEX_APP_SERVER_BIN keeps the highest priority.
*
* Nothing here reaches the network: the app server reads `~/.codex` and runs
* locally. Methods that would require a remote model call are only invoked when
* the UI explicitly asks for them.
*/
import { spawn } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const HERE = path.dirname(fileURLToPath(import.meta.url));
// A6: Linux candidates first after the explicit override, macOS paths kept.
const CANDIDATE_BINARIES = [
process.env.CODEX_APP_SERVER_BIN,
process.env.CODEX_BIN,
"/opt/hatch-image/bin/codex",
"/Applications/ChatGPT.app/Contents/Resources/codex-cli/CodexCLI.app/Contents/MacOS/codex",
"/Applications/ChatGPT.app/Contents/Resources/codex-cli/bin/codex",
].filter(Boolean);
/** Search PATH for an executable `codex` (last-resort candidate, A6). */
function findCodexOnPath() {
const sep = process.platform === "win32" ? ";" : ":";
for (const dir of (process.env.PATH || "").split(sep)) {
if (!dir) continue;
const p = path.join(dir, "codex");
try {
if (fs.existsSync(p) && fs.statSync(p).isFile()) {
fs.accessSync(p, fs.constants.X_OK);
return p;
}
} catch {
/* ignore */
}
}
return null;
}
export function findCodexBinary() {
for (const p of CANDIDATE_BINARIES) {
try {
if (fs.existsSync(p) && fs.statSync(p).isFile()) return p;
} catch {
/* ignore */
}
}
return findCodexOnPath();
}
/**
* Relay configuration. The web build talks to an OpenAI-compatible relay
* instead of a ChatGPT account, so the app server is started with per-process
* `-c` overrides. Nothing is written to ~/.codex/config.toml — the desktop app
* keeps its own settings and the override dies with this process.
*
* Shape: { providerId, providerName, baseUrl, apiKey, envKey, model, wireApi }
*/
export function loadRelayConfig() {
const file = process.env.CODEX_WEB_RELAY_CONFIG || path.join(HERE, "relay-config.json");
if (!fs.existsSync(file)) return null;
try {
const cfg = JSON.parse(fs.readFileSync(file, "utf8"));
return cfg && cfg.baseUrl ? cfg : null;
} catch {
return null;
}
}
/** Translate relay config into `codex app-server` CLI overrides. */
export function relayArgs(cfg) {
if (!cfg) return [];
const id = cfg.providerId || "relay";
const args = [
"-c", `model_provider=${JSON.stringify(id)}`,
"-c", `model=${JSON.stringify(cfg.model)}`,
"-c", `preferred_auth_method="apikey"`,
"-c", `model_providers.${id}.name=${JSON.stringify(cfg.providerName || id)}`,
"-c", `model_providers.${id}.base_url=${JSON.stringify(cfg.baseUrl)}`,
"-c", `model_providers.${id}.wire_api=${JSON.stringify(cfg.wireApi || "responses")}`,
"-c", `model_providers.${id}.env_key=${JSON.stringify(cfg.envKey || "RELAY_API_KEY")}`,
// Everything that would talk to OpenAI's backend (plugin catalog, account
// endpoints, telemetry) is answered by the mock backend served by the web
// host itself: well-formed local JSON, no request ever leaves the machine,
// and callers see success instead of connection errors.
"-c", `chatgpt_base_url=${JSON.stringify(cfg.chatgptBaseUrl || mockBackendUrl())}`,
];
// Optional safety overrides. Left out by default so the app server keeps
// whatever ~/.codex/config.toml says; set them in relay-config.json to pin
// the sandbox for a shared or unattended browser build.
if (cfg.approvalPolicy) args.push("-c", `approval_policy=${JSON.stringify(cfg.approvalPolicy)}`);
if (cfg.sandboxMode) args.push("-c", `sandbox_mode=${JSON.stringify(cfg.sandboxMode)}`);
return args;
}
/** The web host serves the ChatGPT-backend stand-in under /mock. */
function mockBackendUrl() {
const port = process.env.CODEX_WEB_PORT || "8095";
return `http://127.0.0.1:${port}/mock`;
}
const DEFAULT_TIMEOUT_MS = 30_000;
/**
* Methods the app server can leave unanswered when the surrounding desktop
* infrastructure is absent. MCP discovery is the only one today: with no MCP
* servers configured there is nothing to enumerate, and the request simply
* never completes. Answer it locally after a short grace period.
*/
const METHOD_TIMEOUT_MS = { "mcpServerStatus/list": 6_000 };
const METHOD_FALLBACKS = { "mcpServerStatus/list": { data: [] } };
// ------------------------------------------------------------------ A1–A4
// Emulation for methods the vendored UI calls but codex 0.164.0-alpha.9 does
// not implement (verified: the binary answers them with `unknown variant`).
// Only the seven method names listed here are intercepted; every other
// method passes through to the real app server untouched.
//
// A1 thread/stop -> turn/interrupt (the Codex App stop button is
// exactly a turn/interrupt; host.py confirms this).
// A2 thread/rollback -> thread/revert (exists in this binary).
// A3 thread/startAeon -> thread/start. Aeon is an experimental mode with
// no entry point in this binary, so this is a
// downgrade to an ordinary start, not Aeon.
// A4 interactive/* -> the interactive-sessions surface does not exist
// in this binary. *list methods answer with empty
// lists (no sessions are fabricated — the UI shows
// "no live sessions"); upload / sandbox read answer
// with a structured -32601 method-not-found error
// instead of fake data. (Per docs/codex-native-ui-
// alignment.md A4: list 类回空列表.)
const EMULATED_REDIRECTS = {
"thread/stop": "turn/interrupt",
"thread/rollback": "thread/revert",
"thread/startAeon": "thread/start",
};
const INTERACTIVE_EMPTY_LIST_METHODS = new Set([
"interactive/liveSessions/list",
"interactive/sessionSandbox/list",
]);
const INTERACTIVE_UNAVAILABLE_METHODS = new Set([
"interactive/session/upload",
"interactive/sessionSandbox/read",
]);
export class AppServerBridge {
constructor({ log = console, onNotification = null } = {}) {
this.log = log;
this.onNotification = onNotification;
this.child = null;
this.seq = 0;
this.pending = new Map();
this.buffer = "";
this.ready = null;
this.notifications = [];
this.waiter = null;
this.lastError = null;
}
get binary() {
return findCodexBinary();
}
/** Spawn the app server (once) and complete the JSON-RPC handshake. */
async ensure() {
if (this.ready) return this.ready;
this.ready = (async () => {
const bin = this.binary;
if (!bin) throw new Error("codex binary not found");
const relay = loadRelayConfig();
const args = ["app-server", ...relayArgs(relay)];
const env = { ...process.env, RUST_LOG: process.env.RUST_LOG ?? "error" };
if (relay?.apiKey) env[relay.envKey || "RELAY_API_KEY"] = relay.apiKey;
const child = spawn(bin, args, { stdio: ["pipe", "pipe", "pipe"], env });
this.child = child;
if (relay) {
this.log.log?.(`[app-server] relay ${relay.baseUrl} model=${relay.model}`);
}
child.stdout.setEncoding("utf8");
child.stdout.on("data", (chunk) => this.#onStdout(chunk));
child.stderr.setEncoding("utf8");
// Full, untruncated stderr for diagnosis; ANSI colour codes included.
this.stderrFile = fs.createWriteStream("/tmp/codex-appserver-stderr.log", { flags: "a" });
child.stderr.on("data", (chunk) => {
const text = String(chunk);
this.stderrFile?.write(text);
const clean = text.trim();
if (clean && !/unrecognized configuration settings|is ignored/.test(clean)) {
this.lastError = clean.slice(0, 400);
// Backend-shape mismatches surface here (the mock backend answers
// every path; a parse complaint means a shape to improve).
this.log.warn?.(`[app-server] ${clean.slice(0, 200)}`);
}
});
child.on("exit", (code, signal) => {
this.log.warn?.(`[app-server] exited code=${code} signal=${signal}`);
this.child = null;
this.ready = null;
for (const { reject } of this.pending.values()) {
reject(new Error("app-server exited"));
}
this.pending.clear();
});
await this.request("initialize", {
clientInfo: { name: "codex-web", title: "Codex Web", version: "1.0.0" },
// The renderer calls `collaborationMode/list` and friends, which the
// server rejects with -32600 unless the client opts into the
// experimental surface during the handshake.
capabilities: { experimentalApi: true },
});
this.log.log?.("[app-server] ready");
return true;
})();
this.ready.catch((err) => {
this.log.error?.(`[app-server] failed: ${err.message}`);
this.ready = null;
});
return this.ready;
}
#onStdout(chunk) {
this.buffer += chunk;
let nl;
while ((nl = this.buffer.indexOf("\n")) !== -1) {
const line = this.buffer.slice(0, nl).trim();
this.buffer = this.buffer.slice(nl + 1);
if (!line) continue;
let msg;
try {
msg = JSON.parse(line);
} catch {
continue;
}
if (msg.id != null && this.pending.has(msg.id) && !msg.method) {
const { resolve, reject } = this.pending.get(msg.id);
this.pending.delete(msg.id);
msg.error ? reject(Object.assign(new Error(msg.error.message ?? "rpc error"), { rpc: msg.error })) : resolve(msg.result);
continue;
}
if (msg.method) {
// Server-initiated request (has id) or notification (no id). Both go on
// the same stream; the renderer distinguishes them by `id`.
this.notifications.push(msg);
if (this.notifications.length > 2000) this.notifications.shift();
if (this.onNotification) {
try {
this.onNotification(msg);
} catch (err) {
this.log.warn?.(`[app-server] onNotification handler failed: ${err?.message}`);
}
}
this.#wake();
}
}
}
/** Resolve the pending long-poll, if any. */
#wake() {
const waiter = this.waiter;
if (!waiter) return;
this.waiter = null;
clearTimeout(waiter.timer);
waiter.resolve();
}
/**
* Wait until at least one notification is queued (or the timeout fires), then
* hand back everything that accumulated. Long-polling keeps the renderer in
* step with the app server without a websocket.
*/
waitForNotifications(timeoutMs = 15_000) {
if (this.notifications.length) return Promise.resolve(this.drainNotifications());
return new Promise((resolve) => {
const timer = setTimeout(() => {
this.waiter = null;
resolve(this.drainNotifications());
}, timeoutMs);
this.waiter = { resolve: () => resolve(this.drainNotifications()), timer };
});
}
request(method, params) {
// A1–A3: redirect to the real method that implements the same action.
// Only these three names are redirected; anything else falls through.
if (method in EMULATED_REDIRECTS) {
const target = EMULATED_REDIRECTS[method];
this.log.log?.(`[app-server] emulated ${method} -> ${target}`);
return this.ensure().then(() => this.request(target, params));
}
// A4: no live sessions exist in this binary — answer empty, fabricate none.
// The three list-shaped fields cover the shapes the renderer dereferences;
// all three are empty.
if (INTERACTIVE_EMPTY_LIST_METHODS.has(method)) {
return Promise.resolve({ sessions: [], data: [], items: [] });
}
// A4: interactive session upload / sandbox access is unavailable in
// codex 0.164.0-alpha.9. Structured -32601, exactly as the app server
// reports an unknown method — the renderer surfaces it, nothing is faked.
if (INTERACTIVE_UNAVAILABLE_METHODS.has(method)) {
const error = new Error(
`method not found: ${method} (interactive sessions not available in codex 0.164.0-alpha.9)`,
);
error.code = -32601;
error.rpc = { code: -32601, message: error.message };
return Promise.reject(error);
}
if (!this.child) return Promise.reject(new Error("app-server not running"));
const id = ++this.seq;
const payload = JSON.stringify({ jsonrpc: "2.0", id, method, params: params ?? {} });
const timeoutMs = METHOD_TIMEOUT_MS[method] ?? DEFAULT_TIMEOUT_MS;
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
this.pending.delete(id);
// A few methods never answer in a headless build (MCP discovery has no
// servers to enumerate). Resolve with the truthful empty result instead
// of leaving the renderer's query spinning for the full timeout.
if (method in METHOD_FALLBACKS) {
resolve(METHOD_FALLBACKS[method]);
return;
}
reject(new Error(`app-server timeout: ${method}`));
}, timeoutMs);
this.pending.set(id, {
resolve: (v) => {
clearTimeout(timer);
resolve(v);
},
reject: (e) => {
clearTimeout(timer);
reject(e);
},
});
this.child.stdin.write(payload + "\n");
});
}
/** Drain queued server notifications (used by the polling endpoint). */
drainNotifications() {
const out = this.notifications;
this.notifications = [];
return out;
}
dispose() {
this.child?.kill();
this.child = null;
this.ready = null;
}
}
@@ -0,0 +1,181 @@
[
"account/bedrock/checkGovCloudRequirements",
"account/bedrock/discover",
"account/bedrock/setup",
"account/gatewayOAuth/cancel",
"account/gatewayOAuth/login",
"account/gatewayOAuth/read",
"account/login/cancel",
"account/login/start",
"account/logout",
"account/rateLimitResetCredit/consume",
"account/rateLimits/read",
"account/read",
"account/sendAddCreditsNudgeEmail",
"account/usage/read",
"account/workspaceMessages/read",
"app/installed",
"app/list",
"app/read",
"collaborationMode/list",
"command/exec",
"command/exec/resize",
"command/exec/terminate",
"command/exec/write",
"config/batchWrite",
"config/mcpServer/reload",
"config/read",
"config/value/write",
"configRequirements/read",
"environment/add",
"environment/info",
"environment/status",
"experimentalFeature/enablement/set",
"experimentalFeature/list",
"externalAgentConfig/detect",
"externalAgentConfig/import",
"externalAgentConfig/import/readHistories",
"externalAgentConfig/import/recordHistory",
"feedback/upload",
"fs/copy",
"fs/createDirectory",
"fs/getMetadata",
"fs/readDirectory",
"fs/readFile",
"fs/remove",
"fs/unwatch",
"fs/watch",
"fs/writeFile",
"fuzzyFileSearch/sessionStart",
"fuzzyFileSearch/sessionStop",
"fuzzyFileSearch/sessionUpdate",
"hooks/list",
"interactive/liveSessions/list",
"interactive/session/upload",
"interactive/sessionSandbox/list",
"interactive/sessionSandbox/read",
"marketplace/add",
"marketplace/remove",
"marketplace/upgrade",
"mcpServer/event/stream/start",
"mcpServer/event/stream/stop",
"mcpServer/oauth/login",
"mcpServer/resource/read",
"mcpServer/tool/call",
"mcpServerStatus/list",
"memory/reset",
"memory/status",
"mock/experimentalMethod",
"model/list",
"modelProvider/capabilities/read",
"permissionProfile/list",
"plugin/install",
"plugin/installed",
"plugin/list",
"plugin/read",
"plugin/reconcile",
"plugin/search",
"plugin/share/checkout",
"plugin/share/delete",
"plugin/share/list",
"plugin/share/save",
"plugin/share/updateTargets",
"plugin/skill/read",
"plugin/uninstall",
"process/kill",
"process/resizePty",
"process/spawn",
"process/writeStdin",
"project/create",
"project/delete",
"project/import",
"project/list",
"project/move",
"project/read",
"project/update",
"remoteControl/client/list",
"remoteControl/client/revoke",
"remoteControl/disable",
"remoteControl/enable",
"remoteControl/pairing/start",
"remoteControl/pairing/status",
"remoteControl/status/read",
"review/start",
"rollout/compress",
"server/diagnostics",
"skills/config/write",
"skills/extraRoots/set",
"skills/list",
"thread/approveGuardianDeniedAction",
"thread/archive",
"thread/attachment/add",
"thread/attachment/list",
"thread/attachment/remove",
"thread/attachmentOwner/list",
"thread/backgroundTerminals/clean",
"thread/backgroundTerminals/list",
"thread/backgroundTerminals/terminate",
"thread/compact/start",
"thread/decrement_elicitation",
"thread/delete",
"thread/fork",
"thread/goal/clear",
"thread/goal/get",
"thread/goal/set",
"thread/increment_elicitation",
"thread/inject_items",
"thread/items/list",
"thread/items/read",
"thread/list",
"thread/loaded/list",
"thread/memoryMode/set",
"thread/metadata/update",
"thread/name/set",
"thread/prediction/request",
"thread/queue/add",
"thread/queue/delete",
"thread/queue/list",
"thread/queue/reorder",
"thread/queue/start",
"thread/queue/update",
"thread/read",
"thread/readState/update",
"thread/realtime/appendAudio",
"thread/realtime/appendSpeech",
"thread/realtime/appendText",
"thread/realtime/listVoices",
"thread/realtime/start",
"thread/realtime/stop",
"thread/resume",
"thread/revert",
"thread/rollback",
"thread/sample",
"thread/sample/cancel",
"thread/search",
"thread/searchOccurrences",
"thread/section/move",
"thread/settings/update",
"thread/shellCommand",
"thread/start",
"thread/startAeon",
"thread/stop",
"thread/timeline/list",
"thread/turns/list",
"thread/unarchive",
"thread/unsubscribe",
"threadSection/create",
"threadSection/delete",
"threadSection/list",
"threadSection/update",
"turn/interrupt",
"turn/settings/update",
"turn/start",
"turn/steer",
"userVerification/cancel",
"userVerification/delete",
"userVerification/enroll",
"userVerification/status",
"userVerification/verify",
"windowsSandbox/readiness",
"windowsSandbox/setupStart"
]
+109
View File
@@ -0,0 +1,109 @@
#!/usr/bin/env node
/**
* Load the local web host in the headless browser, capture console + exceptions,
* then dump the resulting page state and a screenshot.
*
* node host/cdp-load.mjs --port 9333 --url http://127.0.0.1:8095/ --out /tmp/shot.png
*/
import fs from "node:fs";
const argv = process.argv.slice(2);
const argOf = (name, fallback) => {
const i = argv.indexOf(name);
return i !== -1 && argv[i + 1] ? argv[i + 1] : fallback;
};
const PORT = argOf("--port", "9333");
const URL_ = argOf("--url", "http://127.0.0.1:8095/");
const OUT = argOf("--out", "/tmp/codexweb.png");
const SETTLE = Number(argOf("--settle", "6000"));
const version = await (await fetch(`http://127.0.0.1:${PORT}/json/version`)).json();
const ws = new WebSocket(version.webSocketDebuggerUrl);
let seq = 0;
const pending = new Map();
const events = [];
const send = (method, params = {}, sessionId) =>
new Promise((resolve, reject) => {
const id = ++seq;
pending.set(id, { resolve, reject });
const msg = { id, method, params };
if (sessionId) msg.sessionId = sessionId;
ws.send(JSON.stringify(msg));
});
ws.addEventListener("message", (ev) => {
const msg = JSON.parse(ev.data);
if (msg.method) {
if (msg.method === "Runtime.consoleAPICalled") {
const text = (msg.params.args || [])
.map((a) => a.value ?? a.description ?? a.unserializableValue ?? "")
.join(" ")
.slice(0, 600);
events.push(`[${msg.params.type}] ${text}`);
} else if (msg.method === "Runtime.exceptionThrown") {
const d = msg.params.exceptionDetails;
events.push(`[EXCEPTION] ${d.exception?.description ?? d.text}`.slice(0, 2200));
} else if (msg.method === "Log.entryAdded") {
const e = msg.params.entry;
events.push(`[log:${e.level}] ${e.text}`.slice(0, 500));
}
}
if (!msg.id || !pending.has(msg.id)) return;
const { resolve, reject } = pending.get(msg.id);
pending.delete(msg.id);
msg.error ? reject(new Error(JSON.stringify(msg.error).slice(0, 400))) : resolve(msg.result);
});
await new Promise((r) => ws.addEventListener("open", r, { once: true }));
const { targetInfos } = await send("Target.getTargets");
const pages = targetInfos.filter((t) => t.type === "page");
const main = pages.find((t) => t.url.includes("127.0.0.1")) ?? pages[0];
if (!main) {
console.error("no page target");
process.exit(1);
}
const { sessionId } = await send("Target.attachToTarget", { targetId: main.targetId, flatten: true });
await send("Page.enable", {}, sessionId);
await send("Runtime.enable", {}, sessionId);
await send("Log.enable", {}, sessionId);
await send("Page.navigate", { url: URL_ }, sessionId);
// Runtime.enable replays the previous page's console buffer; drop it so the
// report only covers this load.
events.length = 0;
await new Promise((r) => setTimeout(r, SETTLE));
const state = await send(
"Runtime.evaluate",
{
expression: `(() => {
const root = document.querySelector('#root');
const text = (root?.innerText || '').replace(/\\s+/g, ' ').trim();
return JSON.stringify({
url: location.href,
title: document.title,
rootChildren: root ? root.children.length : -1,
rootBytes: root ? root.innerHTML.length : 0,
textLength: text.length,
textSample: text.slice(0, 500),
bootstrapped: !!globalThis.__codexBootstrapped,
hasShim: typeof globalThis.__codexHost,
}, null, 1);
})()`,
returnByValue: true,
},
sessionId,
);
console.log("STATE:", state.result.value);
console.log(`\nEVENTS (${events.length}):`);
for (const e of events.slice(0, 60)) console.log(" " + e);
const shot = await send("Page.captureScreenshot", { format: "png" }, sessionId);
fs.writeFileSync(OUT, Buffer.from(shot.data, "base64"));
console.log(`\nscreenshot: ${OUT} (${fs.statSync(OUT).size} bytes)`);
ws.close();
+417
View File
@@ -0,0 +1,417 @@
/**
* Local app host for the browser build.
*
* The desktop renderer obtains its host through `connect-app-host`, which in
* Electron hands a MessagePort to the main process; the main process answers
* with a capability-RPC object exposing `services` plus a handful of host
* lifecycle hooks. In a browser there is no main process, so this file supplies
* the same object locally:
*
* - data services are generated from the app-server method table and proxied
* to the real local `codex app-server` (see host/app-server-bridge.mjs);
* - lifecycle members that only exist inside Electron are stubbed.
*
* The generated shape matters: `services.thread.list` must resolve to a call
* that issues `thread/list`, `services.config.read` to `config/read`, and so
* on. Anything the UI probes but we do not provide is reported once on the
* console so gaps are visible instead of silent.
*
* Served as a classic script. The marker below is replaced with the app-server
* method list at response time.
*/
(() => {
const METHODS = /*@METHODS@*/ [];
// Every path prefix of every method becomes a namespace object, so feature
// detection (`if (services.terminal) ...`) sees `undefined` for members that
// genuinely do not exist.
const namespaces = new Set();
for (const method of METHODS) {
const parts = method.split("/");
for (let i = 1; i < parts.length; i += 1) namespaces.add(parts.slice(0, i).join("/"));
}
const reported = new Set();
const reportMissing = (path) => {
if (reported.has(path)) return;
reported.add(path);
const stack = (new Error().stack || "").split("\n").slice(2, 4).join(" | ");
console.info("[codex-local-host] unimplemented service member:", path, "@", stack);
};
// Permissive stub for Electron-main-only service namespaces: every unknown
// member is an inert async function, so unguarded calls do not throw. Only
// use this where the renderer never feature-detects the member itself.
const permissive = (label, members = {}) =>
new Proxy(members, {
get(target, prop) {
if (prop in target) return target[prop];
if (typeof prop === "symbol" || prop === "then" || prop === "toJSON") return undefined;
reportMissing(`${label}.${String(prop)}`);
return () => Promise.resolve();
},
});
async function rpc(method, params) {
const res = await fetch("/__codex_rpc", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ method, params: params ?? {} }),
});
if (!res.ok) throw new Error(`app-server HTTP ${res.status}`);
const body = await res.json();
if (body && body.error) {
const err = new Error(body.error.message || `app-server error: ${method}`);
err.code = body.error.code;
throw err;
}
return body ? body.result : undefined;
}
// ------------------------------------------------------- persisted state
// `r$t()` in the renderer resolves `mi.applicationState` and then calls
// `.read(key)` -> `{ value }` and `.write(key, value)` -> `{ success }`.
// The desktop build backs this with an Electron store on disk; localStorage
// gives the same semantics here, so onboarding flags, the selected project
// and the pinned-thread list survive a reload instead of erroring out.
const STATE_PREFIX = "codex.appstate.";
const stateKey = (key) => STATE_PREFIX + String(key);
const applicationState = {
read: async (key) => {
console.info("[codex-local-host] applicationState.read:", String(key).slice(0, 80));
try {
const raw = localStorage.getItem(stateKey(key));
const out = { value: raw == null ? null : JSON.parse(raw) };
console.info("[codex-local-host] applicationState.read ->", String(key).slice(0, 60), JSON.stringify(out).slice(0, 120));
return out;
} catch (e) {
console.info("[codex-local-host] applicationState.read ERROR:", String(e));
return { value: null };
}
},
write: async (key, value) => {
try {
localStorage.setItem(stateKey(key), JSON.stringify(value));
return { success: true };
} catch {
return { success: false };
}
},
remove: async (key) => {
try {
localStorage.removeItem(stateKey(key));
} catch {
/* ignore */
}
return { success: true };
},
keys: async () => {
const out = [];
for (let i = 0; i < localStorage.length; i += 1) {
const k = localStorage.key(i);
if (k && k.startsWith(STATE_PREFIX)) out.push(k.slice(STATE_PREFIX.length));
}
return out;
},
};
// ------------------------------------------------------------- http fetch
// `$_t` calls `httpFetch.fetch(id, request, onUploadProgress)` where
// `request` is `{ url, method, body, headers, keepalive, cache, ... }` and
// awaits `{ response }` or `{ error, status, ... }` — the handle (both the
// promise and the settled value) carries `Symbol.dispose`.
//
// Backend API paths (`/wham/...`, `/backend-api/...`, …) are answered by the
// local mock backend served by the web host — the same policy the app
// server's chatgpt_base_url follows. Nothing ever leaves the page.
const API_PATH_RE = /^\/(wham|backend-api|ces|v1|api|public)\//;
const httpFetch = {
fetch: (id, request, onUploadProgress) => {
const controller = new AbortController();
const dispose = () => controller.abort();
const opts = typeof request === "string" ? { url: request } : request || {};
let url = String(opts.url ?? "");
const method = String(opts.method || "GET").toUpperCase();
if (API_PATH_RE.test(url)) url = "/mock" + url;
const init = {
method,
headers: opts.headers,
signal: controller.signal,
cache: opts.cache,
keepalive: opts.keepalive,
};
if (opts.body != null && method !== "GET" && method !== "HEAD") {
init.body = typeof opts.body === "string" ? opts.body : JSON.stringify(opts.body);
}
const pending = Promise.resolve()
.then(() => fetch(url, init))
.then(
(response) => ({ response, [Symbol.dispose]: dispose }),
(error) => ({
error: String((error && error.message) || error),
status: 0,
errorCode: null,
responseStatus: 0,
errorKind: "network",
[Symbol.dispose]: dispose,
}),
);
// `$_t` disposes the handle *and* the settled value, so both carry one.
pending[Symbol.dispose] = dispose;
return pending;
},
cancel: async () => {},
};
// ---------------------------------------------------- projectless threads
// A conversation that is not attached to a project still needs a working
// directory. The desktop main process allocates one; here the Node host does
// it (host/projectless.mjs) and this is the renderer-side handle.
//
// `createPaths` returns a cancellable resource rather than a promise: the
// caller awaits `.wait()` and disposes the handle when it aborts.
const hostCall = async (path, body) => {
const res = await fetch(path, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`host call ${path} -> HTTP ${res.status}`);
const payload = await res.json();
if (payload && payload.error) throw new Error(payload.error.message || "host call failed");
return payload ? payload.result : undefined;
};
const projectlessThreads = {
createPaths: (options) => {
const controller = new AbortController();
const settled = hostCall("/__codex_projectless", { op: "createPaths", args: options ?? {} });
return {
wait: () => settled,
[Symbol.dispose]: () => controller.abort(),
};
},
readWorkspaceRoot: () => hostCall("/__codex_projectless", { op: "readWorkspaceRoot" }),
};
// ---------------------------------------------------------------- lifecycle
// Members that live in the Electron main process rather than the app server.
// Shapes follow the call sites in the renderer's bootstrap.
const noopDisposable = { [Symbol.dispose]() {} };
const explicit = {
"startup.whenReady": () => Promise.resolve(),
// The renderer reports paint milestones through this and chains `.catch`.
"startup.reach": async () => {},
applicationState,
httpFetch,
projectlessThreads,
// `Q$` falls back to `{ values: {} }` when this is absent, which is the
// right answer for a build with no desktop settings store behind it.
settings: null,
// `remoteConnections` is consumed as `e.catalog.subscribe(fn)` and
// `e.setRemoteControlEnabled(...)`; both must exist and return promises.
remoteConnections: permissive("remoteConnections", {
catalog: { subscribe: () => noopDisposable },
setRemoteControlEnabled: async () => {},
}),
"executionHosts.getDefaultHost": async () => ({ hostId: "local", kind: "local" }),
"executionHosts.getExecutionHostKeys": () => ["local"],
"threadReadState.initialize": async () => {},
"threadReadState.dispose": () => {},
"tracing.exportTraceBatch": async () => {},
"tracing.setSampleRate": async () => ({}),
// Local-only build: no remote telemetry pipeline to feed.
performanceTelemetry: { report: () => {}, flush: async () => {} },
// ---- host-owned app metadata -----------------------------------------
// Normally answered by the Electron main process from the bundle plist.
// Values are derived from the browser environment so the shell can print a
// real platform string instead of "unknown".
"appInfo.get": async () => ({
version: "26.1021.31345",
buildFlavor: "codex",
buildNumber: "26.1021.31345",
systemVersion: navigator.userAgent,
preloadAttribution: null,
chatGptApiBaseUrl: null,
}),
"appInfo.getSystemInfo": async () => ({
platform: "darwin",
osVersion: navigator.userAgent,
arch: "arm64",
release: navigator.userAgent,
}),
// ---- thread configuration ---------------------------------------------
// The desktop main process reads two things off disk before a thread can
// start: developer instructions and the MCP block of the Codex config.
// Neither exists for a browser build. The renderer dereferences
// `.instructions` directly, so the shape matters even when it is empty.
threadConfiguration: {
readDeveloperInstructions: async () => ({ instructions: null }),
readMcpConfig: async () => ({ config: null }),
},
// The desktop shell auto-resolves pending user-input prompts after a
// timeout, and tracks whether a conversation is on screen so it only fires
// for visible chats. A local build never auto-resolves anything, but the
// renderer calls these unconditionally (only the method itself is
// optional-chained), so the namespace must exist.
requestUserInputAutoResolution: {
setConversationPresented: async () => {},
recordConversationActivity: async () => {},
snooze: async () => {},
},
// ---- git integration ----------------------------------------------------
// The desktop main process owns git queries (`RA('git')`). The UI gates the
// composer on the `['git','availability']` query, which is only ever filled
// as a side effect of a `stable-metadata` answer — so the service must
// exist and answer non-null, or Send stays disabled with "Loading…".
//
// A7: instead of the old stub (always `{available:false, …}`), forward to
// the host's POST /__codex_git, which runs read-only git commands inside
// the cwd the renderer passes in params. The renderer calls this as
// request({method, params:{cwd, …}}) (two-arg request(method, params)
// is accepted too). Without a cwd there is nothing to inspect, so fall
// back to the old stub shape for stable-metadata and null otherwise.
// stable-metadata keeps the original six-field shape, now populated.
git: permissive("git", {
request: async (arg1, arg2) => {
let method;
let params = {};
if (typeof arg1 === "string") {
method = arg1;
params = arg2 ?? {};
} else {
({ method, params = {} } = arg1 ?? {});
}
const cwd = typeof params?.cwd === "string" ? params.cwd : null;
if (method === "stable-metadata" && !cwd) {
return { available: false, branch: null, upstream: null, repository: null, state: null, worktree: null };
}
if (!cwd) return null;
try {
const res = await fetch("/__codex_git", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ op: method, cwd }),
});
if (!res.ok) return null;
const body = await res.json();
if (body && body.error) {
// Not a repo / bad cwd: report unavailable instead of throwing,
// so the composer degrades the way the old stub degraded it.
if (method === "stable-metadata") {
return { available: false, branch: null, upstream: null, repository: null, state: null, worktree: null };
}
return null;
}
return body ? body.result ?? null : null;
} catch {
if (method === "stable-metadata") {
return { available: false, branch: null, upstream: null, repository: null, state: null, worktree: null };
}
return null;
}
},
}),
// ---- statsig evaluations ----------------------------------------------
// In the desktop app the main process evaluates Statsig and pushes results
// over this service; the renderer never talks to the network for it. The
// local build pushes one truthful "no evaluations" payload: `payload: null`
// maps to `hasEvaluations: false`, feature defaults apply, and nothing
// ever polls a remote endpoint.
statsigEvaluations: {
subscribe: async (onValue) => {
queueMicrotask(() =>
onValue({
userId: null,
accountId: null,
authMethod: null,
stableId: "codex-web-local",
payload: null,
}),
);
return { [Symbol.dispose]() {} };
},
// The renderer publishes execution evaluations back to the host (which in
// Electron forwards them to the main process). There is nothing to feed
// locally; accept and drop.
publish: async () => {},
},
// Account state comes from the local app server, which reads ~/.codex.
"accountInfo.readAccountInfo": () => rpc("account/read", {}),
};
const explicitNulls = new Set(["histogramCollector", "statsig", "clientCoordination", "terminal", "devboxService"]);
function makeNamespace(path) {
const node = {};
const children = new Set();
for (const candidate of namespaces) {
if (!candidate.startsWith(path ? path + "/" : "")) continue;
const rest = path ? candidate.slice(path.length + 1) : candidate;
if (rest.includes("/")) continue;
children.add(rest);
}
for (const method of METHODS) {
if (!method.startsWith(path ? path + "/" : "")) continue;
const rest = path ? method.slice(path.length + 1) : method;
if (rest.includes("/")) continue;
node[rest] = (params) => rpc(method, params);
}
for (const child of children) {
node[child] = makeNamespace(path ? `${path}/${child}` : child);
}
return node;
}
function buildServices() {
const services = makeNamespace("");
// Overlay the host-only members, then strip the null ones.
for (const key of Object.keys(explicit)) {
const parts = key.split(".");
let cursor = services;
for (let i = 0; i < parts.length - 1; i += 1) {
cursor[parts[i]] = cursor[parts[i]] ?? {};
cursor = cursor[parts[i]];
}
cursor[parts[parts.length - 1]] = explicit[key];
}
for (const key of explicitNulls) services[key] = null;
// Report accesses to members we do not model.
return new Proxy(services, {
get(target, prop, receiver) {
if (typeof prop === "symbol") return Reflect.get(target, prop, receiver);
if (prop === "then" || prop === "toJSON") return undefined;
if (!(prop in target)) {
reportMissing(String(prop));
return undefined;
}
return Reflect.get(target, prop, receiver);
},
});
}
const servicesPromise = Promise.resolve().then(buildServices);
globalThis.__codexLocalHost = () => ({
services: servicesPromise,
onRpcBroken: () => () => {},
});
console.info("[codex-local-host] app host installed", METHODS.length, "methods");
})();
+223
View File
@@ -0,0 +1,223 @@
/**
* Local stand-in for the model wire.
*
* The app server speaks the OpenAI Responses API (`wire_api = "responses"`):
* one POST to `{base_url}/responses` answered with a `text/event-stream` of
* typed events. Producing that stream here keeps every byte of the
* conversation on this machine — no model provider is ever contacted.
*
* Extracted from `web-server.mjs` so the local model lives in one place: the
* host serves it, and any other local caller gets the identical wire shape.
*/
/** Read a JSON request body with a hard size cap. */
export const readJsonBody = (req, limit = 4_000_000) =>
new Promise((resolve, reject) => {
let raw = "";
req.setEncoding("utf8");
req.on("data", (c) => {
raw += c;
if (raw.length > limit) reject(new Error("body too large"));
});
req.on("end", () => {
try {
resolve(raw ? JSON.parse(raw) : {});
} catch (err) {
reject(err);
}
});
req.on("error", reject);
});
/** Collect every text fragment the app server put in the request. */
const collectText = (payload) => {
const texts = [];
const collect = (item) => {
if (!item || typeof item !== "object") return;
if (typeof item.text === "string") texts.push(item.text);
else if (Array.isArray(item.content)) for (const c of item.content) collect(c);
else if (typeof item.content === "string") texts.push(item.content);
};
for (const item of payload?.input ?? []) collect(item);
if (typeof payload?.input === "string") texts.push(payload.input);
return texts.join(" ");
};
/**
* Craft a deterministic local reply from the user's input.
*
* Deterministic arithmetic answers make end-to-end checks (`5+5 -> 10`)
* readable at a glance; anything else echoes back what arrived so a wrong
* request shape is immediately visible in the UI.
*/
export const mockReplyFor = (payload) => {
const joined = collectText(payload);
if (/5\s*\+\s*5/i.test(joined)) return "10";
if (/3\s*\+\s*3/i.test(joined)) return "6";
if (/2\s*\+\s*2/i.test(joined)) return "4";
if (/hello/i.test(joined)) return "hello from the local mock model";
return `Local mock reply. I received: ${joined.slice(0, 200) || "(empty input)"}`;
};
let callCount = 0;
const sseEvent = (res, type, data) => {
res.write(`event: ${type}\ndata: ${JSON.stringify(data)}\n\n`);
};
/**
* Answer `/mockrelay/v1/*` — the model provider surface.
*
* @param {import("node:http").IncomingMessage} req
* @param {import("node:http").ServerResponse} res
* @param {string} urlPath path beginning with `/mockrelay`
* @param {{ onRequest?: (line: string) => void }} [opts]
*/
export const serveMockRelay = (req, res, urlPath, opts = {}) => {
opts.onRequest?.(`${req.method} ${urlPath} (mock model relay)`);
const route = urlPath.replace(/^\/mockrelay/, "") || "/";
const json = (obj, status = 200) => {
res.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
res.end(JSON.stringify(obj));
};
// Model list — keeps provider probes happy.
if (route === "/v1/models" || route === "/models") {
json({ object: "list", data: [{ id: "gpt-6.1-sol", object: "model", owned_by: "local" }] });
return;
}
if (route === "/v1/responses" || route === "/responses") {
if (req.method !== "POST") {
res.writeHead(405).end("method not allowed");
return;
}
readJsonBody(req)
.then((payload) => {
const reply = mockReplyFor(payload);
const model = typeof payload?.model === "string" ? payload.model : "gpt-6.1-sol";
const n = ++callCount;
const respId = `resp_local_${Date.now().toString(36)}_${n}`;
const msgId = `msg_local_${n}`;
const now = Math.floor(Date.now() / 1000);
const responseShell = (status, extra = {}) => ({
id: respId,
object: "response",
created_at: now,
status,
model,
output: [],
usage: null,
...extra,
});
res.writeHead(200, {
"content-type": "text/event-stream; charset=utf-8",
"cache-control": "no-store",
connection: "keep-alive",
});
sseEvent(res, "response.created", { type: "response.created", sequence_number: 0, response: responseShell("in_progress") });
sseEvent(res, "response.in_progress", { type: "response.in_progress", sequence_number: 1, response: responseShell("in_progress") });
const messageItem = { type: "message", id: msgId, role: "assistant", status: "in_progress", content: [] };
sseEvent(res, "response.output_item.added", {
type: "response.output_item.added",
sequence_number: 2,
output_index: 0,
item: messageItem,
});
// Stream the reply in small chunks so the renderer's delta path runs.
const chunks = reply.match(/.{1,8}/gs) ?? [reply];
let seq = 3;
for (const chunk of chunks) {
sseEvent(res, "response.output_text.delta", {
type: "response.output_text.delta",
sequence_number: seq++,
item_id: msgId,
output_index: 0,
content_index: 0,
delta: chunk,
});
}
const completedItem = {
type: "message",
id: msgId,
role: "assistant",
status: "completed",
content: [{ type: "output_text", text: reply, annotations: [] }],
};
sseEvent(res, "response.output_text.done", {
type: "response.output_text.done",
sequence_number: seq++,
item_id: msgId,
output_index: 0,
content_index: 0,
text: reply,
});
sseEvent(res, "response.output_item.done", {
type: "response.output_item.done",
sequence_number: seq++,
output_index: 0,
item: completedItem,
});
const finalResponse = responseShell("completed", {
output: [completedItem],
usage: {
input_tokens: 0,
input_tokens_details: { cached_tokens: 0 },
output_tokens: 1,
output_tokens_details: { reasoning_tokens: 0 },
total_tokens: 1,
},
});
sseEvent(res, "response.completed", { type: "response.completed", sequence_number: seq++, response: finalResponse });
res.end();
})
.catch(() => {
res.writeHead(400, { "content-type": "application/json; charset=utf-8" });
res.end(JSON.stringify({ error: { message: "bad request body" } }));
});
return;
}
json({ error: { message: `mock relay: unhandled path ${route}` } }, 404);
};
/**
* Answer the ChatGPT-backend surface (`/mock/*`) with truthful local shapes.
*
* Every response is either the exact JSON the desktop main process would hand
* over, or an empty-but-valid object. The point is that nothing errors and
* nothing leaves the machine.
*/
export const serveMockBackend = (req, res, urlPath, opts = {}) => {
opts.onRequest?.(`${req.method} ${urlPath}`);
const json = (obj, status = 200) => {
res.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
res.end(JSON.stringify(obj));
};
const p = urlPath.replace(/^\/mock/, "") || "/";
// Cloud MCP endpoint (ChatGPT-hosted app connectors). 404 makes the app
// server mark these unavailable immediately — a semantically empty 200
// would be retried and block thread startup on MCP enumeration.
if (/^\/api\/codex\/ps\/mcp/.test(p)) {
json({ error: { message: "cloud connectors are unavailable in a local build" } }, 404);
return;
}
if (/^\/wham\/tasks\/list/.test(p)) {
json({ items: [], cursor: null });
return;
}
if (/plugin/i.test(p)) {
json({ schema_version: 1, fetched_at: new Date().toISOString(), plugins: [] });
return;
}
json({});
};
+62
View File
@@ -0,0 +1,62 @@
/**
* Projectless thread workspaces.
*
* Codex keeps two kinds of conversation: one attached to a project directory and
* one that starts from nothing. The desktop main process owns the second kind —
* it picks a folder under the user's workspace root, creates it, and hands the
* paths back to the renderer through `projectlessThreads.createPaths`.
*
* The browser cannot touch the filesystem, so the Node host does it here and
* exposes the same three paths over HTTP:
*
* cwd the directory this conversation runs in
* outputDirectory where generated files land (same folder)
* workspaceRoot the shared root that contains every thread
*
* Layout mirrors the desktop app: <root>/<YYYY-MM-DD>/<slug>
*/
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
export const WORKSPACE_ROOT =
// Linux server default: ~/Codex. The desktop default ~/Documents/Codex
// does not exist on a server, so it is not used here.
process.env.CODEX_WEB_WORKSPACE_ROOT || path.join(os.homedir(), "Codex");
export function ensureWorkspaceRoot() {
fs.mkdirSync(WORKSPACE_ROOT, { recursive: true });
return WORKSPACE_ROOT;
}
/** Lowercase, collapse anything awkward, keep CJK, cap the length. */
function slugify(value) {
const text = String(value ?? "").trim();
if (!text) return "";
return text
.toLowerCase()
.replace(/[^\p{Letter}\p{Number}]+/gu, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 40);
}
export function createProjectlessPaths({ directoryName, prompt } = {}) {
const root = ensureWorkspaceRoot();
const day = new Date().toISOString().slice(0, 10);
const base = slugify(directoryName) || slugify(prompt) || "thread";
let dir = path.join(root, day, base);
let suffix = 1;
while (fs.existsSync(dir)) {
suffix += 1;
dir = path.join(root, day, `${base}-${suffix}`);
}
fs.mkdirSync(dir, { recursive: true });
return { cwd: dir, outputDirectory: dir, workspaceRoot: root };
}
export function readWorkspaceRoot() {
return { workspaceRoot: ensureWorkspaceRoot() };
}
@@ -0,0 +1,19 @@
# relay-config.json — mock 现状与接真实 relay
`relay-config.json` 保持 UI 栈自带的 Bumian 配置原样,未换 provider、未写任何密钥值。
## 当前状态:模型调用走本地 mock
- `baseUrl` 是 `http://127.0.0.1:8095/mockrelay/v1`:app-server 的模型请求由本机 `host/mock-model.mjs` 用确定性替身回答,不出本机、不产生真实调用、不花钱。
- `relayBaseUrl`(`https://www.bumianapi.top/v1`)只写在 JSON 里,host 代码没有引用它,当前不生效。
- `apiKey` 为空字符串:真实 relay 需要 key 时,key 值绝不写进本文件。
## 接真实 relay:两步
1. 把 `baseUrl` 的值改为 `relayBaseUrl` 的值(即 `https://www.bumianapi.top/v1`)。
只改这一处;`providerId` / `providerName` / `model` / `wireApi` / `envKey` 不动。
2. 在启动 UI host 的环境里设置 `BUMIAN_API_KEY` 环境变量(key 值只进环境,不进文件、不进仓库):
`BUMIAN_API_KEY=<你的 key> scripts/run-codex-ui.sh`
改完后 app-server 启动时按 `relay-config.json` 的 `-c` 覆盖走真实 relay;`chatgptBaseUrl`
仍指向本机 `/mock`,插件目录、账号端点、遥测等 ChatGPT 后端调用继续由本地替身回答。
@@ -0,0 +1,11 @@
{
"providerId": "bumian",
"providerName": "Bumian Relay",
"baseUrl": "http://127.0.0.1:8095/mockrelay/v1",
"relayBaseUrl": "https://www.bumianapi.top/v1",
"apiKey": "",
"envKey": "BUMIAN_API_KEY",
"model": "gpt-6.1-sol",
"wireApi": "responses",
"chatgptBaseUrl": "http://127.0.0.1:8095/mock"
}
+323
View File
@@ -0,0 +1,323 @@
/* Codex web host shim — browser-only replacement for the Electron preload.
*
* Loaded as a classic script before the module entry. It provides the two
* pieces of the Electron shell the renderer cannot live without:
*
* window.electronBridge — the preload API surface. Only the members the
* renderer actually probes are meaningful; the rest
* resolve to inert promises so .then/.catch chains
* stay valid.
* the app-server bridge — request envelopes sent through
* sendMessageFromView are forwarded to the local
* codex app server and answered on the window
* message bus, which is where the renderer listens.
*/
(() => {
const noop = () => {};
globalThis.__codexOut = [];
globalThis.__codexIn = [];
let rpcCount = 0;
// ------------------------------------------------------------------ inbound
// The renderer validates inbound messages with `event.source === null` (it
// skips the origin check in that case), which is exactly what a synthesized
// MessageEvent gives us.
const dispatchInbound = (payload) => {
globalThis.__codexIn.push(payload);
window.dispatchEvent(new MessageEvent("message", { data: payload }));
};
const forwardRequest = (msg) => {
const { id, method, params } = msg.request;
rpcCount += 1;
if (rpcCount <= 500) console.info("[codex-web] rpc ->", method);
fetch("/__codex_rpc", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ method: method, params: params ?? {} }),
})
.then((res) => res.json())
.then((body) => {
// The renderer's app-server request client reads the reply from
// `message: { id, result | error }`. `response` is kept alongside it
// because the app-host side of the same bus uses that spelling.
const message =
body && body.error
? { id: id, error: { code: body.error.code ?? -32000, message: body.error.message ?? "rpc failed" } }
: { id: id, result: body ? body.result : null };
dispatchInbound({
type: "mcp-response",
hostId: msg.hostId,
message: message,
response: message,
requestMethod: method,
receivedAtMs: Date.now(),
});
})
.catch((err) => {
const message = { id: id, error: { code: -32000, message: String((err && err.message) || err) } };
dispatchInbound({
type: "mcp-response",
hostId: msg.hostId,
message: message,
response: message,
requestMethod: method,
receivedAtMs: Date.now(),
});
});
return Promise.resolve();
};
// ---------------------------------------------------------------- events
// Everything the app server says on its own initiative — streaming deltas,
// turn progress, server-initiated approval prompts — arrives on this
// long-poll. The renderer subscribes to it as `mcp-notification`.
globalThis.__codexEvents = [];
let polling = false;
const pumpEvents = async () => {
if (polling) return;
polling = true;
while (polling) {
try {
const res = await fetch("/__codex_events", { cache: "no-store" });
const body = await res.json();
for (const event of body.events || []) {
globalThis.__codexEvents.push(event);
if (globalThis.__codexEvents.length > 400) globalThis.__codexEvents.shift();
if (event.id != null) {
// Server-initiated request: the renderer owns the answer path, which
// this shim does not model yet. Keep it visible instead of dropping.
globalThis.__codexServerRequests = globalThis.__codexServerRequests || [];
globalThis.__codexServerRequests.push(event);
continue;
}
dispatchInbound({
type: "mcp-notification",
hostId: "local",
method: event.method,
params: event.params ?? {},
});
}
} catch {
await new Promise((r) => setTimeout(r, 1000));
}
}
};
pumpEvents();
// ------------------------------------------------------------- electron API
// Synchronous getters first: callers treat these as plain values.
const SYNC = {
getInitialSidebarBootstrap: () => null,
getConfig: () => null,
getPlatform: () => "darwin",
getLocale: () => "zh-CN",
getVersion: () => "26.1021.31345",
isPackaged: () => true,
getWindowType: () => "electron",
getZoomLevel: () => 0,
isDeviceCheckSupported: () => true,
isIntelMacBuild: () => false,
getSystemThemeVariant: () => "light",
subscribeToSystemThemeVariant: () => noop,
getBuildFlavor: () => "codex",
getAppSessionId: () => "codex-web-local",
getDesktopUserAgent: () => navigator.userAgent,
getSentryInitOptions: () => ({ appVersion: "26.1021.31345", buildFlavor: "codex" }),
getSharedObjectSnapshotValue: () => undefined,
};
const makeBridge = () =>
new Proxy(SYNC, {
get(target, prop) {
if (prop in target) return target[prop];
if (prop === "then") return undefined; // never a thenable
if (typeof prop === "symbol") return undefined;
if (prop === "sendMessageFromView") {
return (msg) => {
globalThis.__codexOut.push(msg);
if (msg && msg.type === "mcp-request" && msg.request && typeof msg.request.method === "string") {
return forwardRequest(msg);
}
return Promise.resolve();
};
}
// Unknown member: an inert async function, so feature probes see
// "present" and promise chains stay valid.
return () => Promise.resolve();
},
has: () => true,
});
if (typeof window.electronBridge === "undefined") window.electronBridge = makeBridge();
if (typeof window.codexWindowType === "undefined") window.codexWindowType = "electron";
if (typeof window.__SENTRY_IPC__ === "undefined") window.__SENTRY_IPC__ = { sendMessage: noop };
window.__codexDesktop = window.__codexDesktop || {};
// ------------------------------------------------------------- offline net
// Nothing leaves the machine. Absolute URLs get a well-formed empty JSON body
// so callers fail softly instead of hitting the CSP. The remaining browser
// transports — XHR, sendBeacon, WebSocket, EventSource — are stubbed the same
// way: local requests pass through, everything else succeeds locally with
// empty data.
const realFetch = window.fetch.bind(window);
const isLocal = (url) =>
!url ||
url.startsWith("/") ||
url.startsWith("data:") ||
url.startsWith("blob:") ||
url.startsWith(location.origin);
window.fetch = (input, init) => {
const url = typeof input === "string" ? input : (input && input.url) || "";
if (isLocal(url)) return realFetch(input, init);
console.info("[codex-web] outbound:", (init?.method || "GET"), url.slice(0, 140));
const json = (obj, status = 200) =>
Promise.resolve(new Response(typeof obj === "string" ? obj : JSON.stringify(obj), {
status,
headers: { "content-type": "application/json" },
}));
// Statsig telemetry hosts. The SDK requires its initialize payload to
// carry `has_updates` (see _typedJsonParse), so hand back a valid empty
// evaluation set — every gate then resolves to "off" locally and the SDK
// completes initialization instead of retrying forever.
if (/ab\.chatgpt\.com|api\.oaistatsig\.com|statsigcdn\.openai\.com/i.test(url)) {
if (/\/initialize/i.test(url)) {
return json({
has_updates: true,
hash_used: "djb2",
time: Date.now(),
feature_gates: {},
dynamic_configs: {},
layer_configs: {},
param_stores: {},
});
}
if (/sdk_exception/i.test(url)) {
return Promise.resolve(new Response(null, { status: 204 }));
}
// rgstr / download_config_specs: accepted-and-empty.
return Promise.resolve(new Response(null, { status: 202 }));
}
// Everything else: generic empty JSON success.
return json({});
};
// XMLHttpRequest: same policy. A cross-origin send completes immediately
// with `{}` and status 200, on the microtask queue so ordering stays sane.
const XHR = window.XMLHttpRequest;
if (XHR && XHR.prototype) {
const openOrig = XHR.prototype.open;
const sendOrig = XHR.prototype.send;
XHR.prototype.open = function (method, url, ...rest) {
this.__codexUrl = String(url ?? "");
return openOrig.call(this, method, url, ...rest);
};
XHR.prototype.send = function (body) {
if (isLocal(this.__codexUrl)) return sendOrig.call(this, body);
const payload = this.responseType === "json" ? {} : "{}";
queueMicrotask(() => {
try {
Object.defineProperty(this, "readyState", { value: 4, configurable: true });
Object.defineProperty(this, "status", { value: 200, configurable: true });
Object.defineProperty(this, "statusText", { value: "OK", configurable: true });
Object.defineProperty(this, "responseText", { value: payload, configurable: true });
Object.defineProperty(this, "response", { value: payload, configurable: true });
Object.defineProperty(this, "responseURL", { value: this.__codexUrl, configurable: true });
this.dispatchEvent(new Event("readystatechange"));
this.dispatchEvent(new ProgressEvent("load"));
this.dispatchEvent(new ProgressEvent("loadend"));
} catch {
/* the request object went away; nothing to satisfy */
}
});
};
}
// sendBeacon is fire-and-forget by contract; "delivered" locally means dropped.
try {
if (navigator.sendBeacon) navigator.sendBeacon = () => true;
} catch {
/* some engines make it a getter; the CSP covers those */
}
// WebSocket: hand back a socket that is already closed. No network dial, no
// browser console noise, and well-behaved clients see a terminal state.
const RealWS = window.WebSocket;
if (RealWS) {
class ClosedSocket extends EventTarget {
constructor(url) {
super();
this.url = String(url ?? "");
this.readyState = 3; // CLOSED
this.bufferedAmount = 0;
this.extensions = "";
this.protocol = "";
this.binaryType = "blob";
queueMicrotask(() => {
this.dispatchEvent(new Event("error"));
this.dispatchEvent(new CloseEvent("close", { wasClean: true, code: 1000 }));
});
}
send() {
throw new DOMException("The socket is closed.", "InvalidStateError");
}
close() {}
}
for (const k of ["CONNECTING", "OPEN", "CLOSING", "CLOSED"]) ClosedSocket[k] = RealWS[k];
window.WebSocket = ClosedSocket;
}
// EventSource: a stream that closes immediately, for the same reason.
if (window.EventSource) {
class ClosedStream extends EventTarget {
constructor(url) {
super();
this.url = String(url ?? "");
this.readyState = 2; // CLOSED
this.withCredentials = false;
queueMicrotask(() => this.dispatchEvent(new Event("error")));
}
close() {}
}
window.EventSource = ClosedStream;
}
console.info("[codex-web] offline host shim installed");
})();
// ------------------------------------------------------------- config preseed
// The scope-query bridge only fetches from mounted React readers, and the
// config/effective + permission-profiles queries have no mounted reader in a
// browser fallback (no project tree, no workspace picker). Prime both from the
// local app server up front so the composer's agent-mode resolution sees real
// data on first render instead of a query that would stay pending forever.
(() => {
const rpc = (method, params) =>
fetch("/__codex_rpc", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ method, params: params ?? null }),
})
.then((r) => r.json())
.then((b) => (b && b.result ? b.result : null))
.catch(() => null);
globalThis.__codexCfg = null;
globalThis.__codexPerms = null;
rpc("config/read", { includeLayers: true, cwd: null }).then((r) => {
if (r && r.config) {
globalThis.__codexCfg = r;
console.info("[codex-web] config preseeded");
}
});
rpc("permissionProfile/list", { cursor: null, limit: 50, cwd: null }).then((r) => {
if (r && Array.isArray(r.data)) {
globalThis.__codexPerms = r.data;
console.info("[codex-web] permission profiles preseeded");
}
});
})();
@@ -0,0 +1,7 @@
{
"source": "/tmp/codex-asar/webview",
"vendoredAt": "2026-10-27T03:31:32.962Z",
"fileCount": 20918,
"indexHtmlSha256": "85e5c5b4ce1cd7e0fc42ccbac7fe6d9c6ac7b2eb4b8a7f77beb39d280fdca9f5",
"verification": "diff -rq clean; identical sha256"
}
@@ -0,0 +1,71 @@
#!/usr/bin/env node
/**
* Vendor the original webview verbatim into ./original.
*
* Source: the extracted ChatGPT.app/Contents/Resources/app.asar, i.e.
* <extracted-asar>/webview
*
* Nothing is rewritten. The copy is byte-for-byte identical, which is what makes
* the UI 100% the original rather than an approximation.
*
* Usage:
* node host/vendor-original.mjs --from /tmp/codex-asar/webview
* node host/vendor-original.mjs --from /path/to/webview --clean
*/
import fs from "node:fs";
import path from "node:path";
import crypto from "node:crypto";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(__dirname, "..");
const args = process.argv.slice(2);
const getArg = (name, fallback) => {
const i = args.indexOf(name);
return i >= 0 && args[i + 1] ? args[i + 1] : fallback;
};
const from = path.resolve(getArg("--from", "/tmp/codex-asar/webview"));
const to = path.resolve(projectRoot, getArg("--to", "original"));
const clean = args.includes("--clean");
if (!fs.existsSync(path.join(from, "index.html"))) {
console.error(`[vendor] not a webview directory: ${from}`);
process.exit(1);
}
if (clean && fs.existsSync(to)) {
console.log(`[vendor] removing ${to}`);
fs.rmSync(to, { recursive: true, force: true });
}
fs.mkdirSync(to, { recursive: true });
console.log(`[vendor] copying ${from} -> ${to}`);
fs.cpSync(from, to, { recursive: true, force: true });
// Manifest (written outside ./original so the vendored tree stays untouched).
function sha256(file) {
return crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
}
function countFiles(dir) {
let n = 0;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
n += entry.isDirectory() ? countFiles(path.join(dir, entry.name)) : 1;
}
return n;
}
const manifest = {
source: from,
vendoredAt: new Date().toISOString(),
fileCount: countFiles(to),
indexHtmlSha256: sha256(path.join(to, "index.html")),
};
fs.writeFileSync(
path.join(projectRoot, "host", "vendor-manifest.json"),
JSON.stringify(manifest, null, 2),
);
console.log(`[vendor] done — ${manifest.fileCount} files`);
console.log(`[vendor] index.html sha256 ${manifest.indexHtmlSha256}`);
+718
View File
@@ -0,0 +1,718 @@
#!/usr/bin/env node
/**
* Local web host for the vendored Codex webview.
*
* `original/` stays byte-identical — every adaptation happens in the HTTP
* response layer, so the vendored tree remains a clean reference.
*
* The desktop renderer is not a plain web app; three pieces of the Electron
* shell are load-bearing and are replaced here:
*
* 1. CSP. The shipped policy is `default-src 'none'` with a connect-src that
* only whitelists `wss://chatgpt.com`. A browser tab could not reach
* anything, and we do not want it to: the policy is rewritten to `'self'`
* so the page is fully local.
* 2. The app host. `connect-app-host` creates a MessageChannel and posts one
* port to the preload, which hands it to the main process. Here the port is
* never used: `__codexLocalHost` answers instead (host/local-host.js).
* 3. Host services. The bootstrap awaits `appHost.services`, which the main
* process resolves with its service layer. Our local host resolves it with
* a proxy onto the real `codex app-server` (host/app-server-bridge.mjs).
*
* Usage:
* node host/web-server.mjs [--port 8095] [--root original]
*/
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import http from "node:http";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { AppServerBridge } from "./app-server-bridge.mjs";
import { serveMockRelay } from "./mock-model.mjs";
import { createProjectlessPaths, readWorkspaceRoot, WORKSPACE_ROOT } from "./projectless.mjs";
const HERE = path.dirname(fileURLToPath(import.meta.url));
const PROJECT = path.resolve(HERE, "..");
const argv = process.argv.slice(2);
const argOf = (name, fallback) => {
const i = argv.indexOf(name);
return i !== -1 && argv[i + 1] ? argv[i + 1] : fallback;
};
const PORT = Number(argOf("--port", process.env.CODEX_WEB_PORT || "8095"));
// --root wins; otherwise CODEX_UI_ORIGINAL_ROOT (set by scripts/run-codex-ui.sh),
// otherwise the in-repo `original/` directory (never committed — see §4).
const ROOT = path.resolve(PROJECT, argOf("--root", process.env.CODEX_UI_ORIGINAL_ROOT || "original"));
if (!fs.existsSync(path.join(ROOT, "index.html"))) {
console.error(`[web] missing index.html under ${ROOT}`);
process.exit(1);
}
const METHODS = JSON.parse(fs.readFileSync(path.join(HERE, "appserver-methods.json"), "utf8"));
const bridge = new AppServerBridge();
// --------------------------------------------------------------- CSP rewrite
// Fully local: nothing outside this origin is reachable. Blob/data stay allowed
// because the app builds workers and assets in memory.
const WEB_CSP = [
"default-src 'self'",
"img-src 'self' data: blob:",
"script-src 'self' 'unsafe-inline' 'unsafe-eval' 'wasm-unsafe-eval' blob:",
"style-src 'self' 'unsafe-inline'",
"font-src 'self' data:",
"connect-src 'self' data: blob:",
"media-src 'self' data: blob:",
"worker-src 'self' blob:",
"frame-src 'self' blob:",
"child-src 'self' blob:",
].join("; ");
const MIME = {
".html": "text/html; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".mjs": "text/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".json": "application/json; charset=utf-8",
".svg": "image/svg+xml",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".webp": "image/webp",
".gif": "image/gif",
".ico": "image/x-icon",
".woff": "font/woff",
".woff2": "font/woff2",
".ttf": "font/ttf",
".otf": "font/otf",
".wasm": "application/wasm",
".map": "application/json; charset=utf-8",
".txt": "text/plain; charset=utf-8",
".wav": "audio/wav",
".mp3": "audio/mpeg",
};
/** Rewrite rules applied on the fly; `original/` on disk is never touched. */
const JS_PATCHES = [
{
name: "local-app-host",
// `connect-app-host` opens a MessageChannel and posts one port to the
// preload, which forwards it to the Electron main process. There is no main
// process here, so answer with the local host instead.
find: /function (\w+)\((\w+)\)\{let\{port1:(\w+),port2:(\w+)\}=new MessageChannel;return window\.postMessage\(\{type:`connect-app-host`/g,
replace: (_m, fn, arg, p1, p2) =>
`function ${fn}(${arg}){if(globalThis.__codexLocalHost)return globalThis.__codexLocalHost(${arg});` +
`let{port1:${p1},port2:${p2}}=new MessageChannel;return window.postMessage({type:\`connect-app-host\``,
},
{
name: "bootstrap-guard",
// The original bootstrap wires up seven main-process facilities: remote
// connection discovery, an execution host, an RPC-broken hook, thread read
// state, Statsig, client coordination and the terminal. Only the first two
// affect the shell; the rest are Electron-only and would abort the whole
// sequence on the first rejection. Keep the parts that matter, guard them,
// and drop the rest.
find: /async function pL\(\)\{[\s\S]*?\}function mL\(\)/g,
replace: () =>
`async function pL(){` +
`const T=(ms)=>new Promise(r=>setTimeout(r,ms));` +
`const guard=async(f,ms)=>{try{return await Promise.race([f(),T(ms)])}catch(e){console.warn("[codex-web] init step skipped:",e&&e.message)}};` +
`await guard(async()=>{$t(kS(lL))},5000);` +
`await guard(async()=>{dr(await Pt.services)},10000);` +
`await guard(async()=>{await ql(H.remoteConnections)},2000);` +
`await guard(async()=>{di(await H.executionHosts?.getDefaultHost())},2000);` +
`globalThis.__codexBootstrapped=!0` +
`}function mL()`,
},
{
name: "skip-shell-gate",
// The shell waits for auth, app-info, os-info and one more readiness signal
// before rendering anything. In the desktop app the main process supplies
// all four; here auth in particular only settles after the app server has
// been asked for an account, which cannot happen until the shell exists.
// Render the shell immediately and let the data arrive underneath it.
find: /if\((\w+)\.isLoading\|\|(\w+)\|\|(\w+)\|\|(\w+)\)\{/g,
replace: (_m, r, a, s, c) =>
`if(console.info("[codex-web] shell gate",JSON.stringify({rLoad:${r}&&${r}.isLoading,a:${a},s:${s},c:${c}})),!1){`,
},
{
name: "mode-probe",
// `Ck` picks the window mode (`app` / `onboarding` / `login`) from a pile of
// readiness signals and falls back to the loading blossom when it comes back
// empty. The guard is a comma expression inside a single `if`, so the anchor
// is the tail of that expression rather than the `if(` itself. Log the
// decision so the missing input is identifiable.
find: /,!(\w+)\|\|(\w+)===\x60loading\x60\)\{/g,
replace: (_m, mode, status) =>
`,console.info("[codex-web] mode",JSON.stringify({mode:${mode},status:${status}})),!${mode}||${status}===\x60loading\x60){`,
},
{
name: "mode-inputs",
// Bind the mode-input object to the global scope on its way into the
// decision function, so a probe can read exactly what the shell decided on.
find: /,(\w+)=(\w+)\(\{bedrockLoginPending:/g,
replace: (_m, out, fn) => `,${out}=${fn}(globalThis.__modeIn={bedrockLoginPending:`,
},
{
name: "mode-decide",
// The window-mode decision gates on two desktop-only concepts:
//
// gatewayOAuthReadiness — an OpenAI gateway handshake that stays `loading`
// forever here because there is no gateway: this build talks to an
// API-key relay, so there is nothing to wait for.
// onboarding completion — the desktop onboarding flow needs a ChatGPT
// account and a backend, neither of which this build has.
//
// Collapse both: honour an explicit "login required", otherwise go straight
// to the app. The relay authenticates with a key, so there is no login step.
find: /(\w+)===\x60loading\x60\?null:\1===\x60required\x60\?\x60login\x60:[\s\S]{0,200}?:\x60welcome\x60\):\x60app\x60\}/g,
replace: (_m, readiness) =>
`${readiness}===\x60required\x60?\x60login\x60:\x60app\x60:\x60app\x60}`,
},
{
name: "config-effective-preseed",
// The scope-query bridge fetches only from mounted React readers. In a
// browser fallback nothing mounts the config/effective query, so `ofo()`
// would read `isPending: true` forever and keep `isAgentModeLoading` true.
// Short-circuit to the preseeded app-server config (host/shim.js).
find: /let\{data:(\w+),isPending:(\w+)\}=ixn\((\w+)===`~`\?null:\3,(\w+)\),/,
replace: (_m, w, T, p, C) =>
`let{data:${w},isPending:${T}}=globalThis.__codexCfg?{data:{config:globalThis.__codexCfg.config,origins:globalThis.__codexCfg.origins??{},layers:null},isPending:!1}:(ixn(${p}===\`~\`?null:${p},${C}),{data:void 0,isPending:!0}),`,
},
{
name: "permission-profiles-preseed",
// Same mount requirement for the permission-profiles query read inside the
// agent-mode hook (`sfo`); short-circuit to the preseeded profile list.
find: /let (\w+)=ns\(kdo,(\w+),(\w+)\),(\w+)=\w+\|\|\w+\|\|\1\.isPending,(\w+)=\4\|\|\1\.isError,/,
replace: (_m, O, T2, D2, k, A) =>
`let ${O}=globalThis.__codexPerms?{data:globalThis.__codexPerms,isPending:!1,isError:!1}:ns(kdo,${T2},${D2}),${k}=h||g||${O}.isPending,${A}=${k}||${O}.isError,`,
},
{
name: "guardian-off",
// The guardian-approvals feature flag derives from a Statsig dynamic
// config the desktop main process evaluates; with a null evaluations
// payload (this build has no ChatGPT account) the guard reads as
// "unavailable" and `ofo()` treats guardian mode as pending forever.
// This build has no guardian capability at all, so pin it off.
find: /N=(\w+)&&oee\(\w+\?\?void 0\)!==!1&&\(\w+===void 0\|\|\w+\),/,
replace: (_m, e) => `N=!1,`,
},
{
name: "statsig-bootstrap-local",
// The renderer fetches its Statsig bootstrap from the ChatGPT backend
// (`POST /wham/statsig/bootstrap`). Without a ChatGPT token that request
// is rejected before it is ever sent, and the SDK retries forever with
// "SDK initialization has not completed" warnings. Serve the bootstrap
// from the local host instead (see /__codex_statsig_bootstrap).
find: /function (\w+)\((\w+),(\w+)\)\{return (\w+)\.safePost\(`\/wham\/statsig\/bootstrap`,\{requestBody:\2,retry:!1,signal:\3\}\)\}/g,
replace: (_m, fn, reqBody, signal) =>
`async function ${fn}(${reqBody},${signal}){` +
`const r=await fetch("/__codex_statsig_bootstrap",{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify(${reqBody}??{}),signal:${signal}});` +
`return r.json()}`,
},
{
name: "expose-app-host",
// setAppHost / setAppServices only assign module-scoped variables, which
// makes the wiring invisible from the outside. Mirror them for diagnostics.
find: /function hSt\((\w+)\)\{_St=\1\}/g,
replace: (_m, v) => `function hSt(${v}){_St=${v};globalThis.__appHost=${v}}`,
},
{
name: "expose-app-services",
find: /function gSt\((\w+)\)\{f\$=\1\}/g,
replace: (_m, v) => `function gSt(${v}){f$=${v};globalThis.__appServices=${v}}`,
},
];
function patchJs(source) {
let out = source;
const applied = [];
for (const rule of JS_PATCHES) {
if (!rule.find.test(out)) continue;
rule.find.lastIndex = 0;
out = out.replace(rule.find, rule.replace);
applied.push(rule.name);
}
return { out, applied };
}
function patchHtml(source) {
let out = source;
out = out.replace(
/<meta http-equiv="Content-Security-Policy" content="[\s\S]*?"\s*\/?>/i,
`<meta http-equiv="Content-Security-Policy" content="${WEB_CSP.replace(/"/g, "&quot;")}" />`,
);
// Both shims must run before the module entry.
if (!out.includes("__codex_shim.js")) {
out = out.replace(
/<script type="module"/i,
`<script src="/__codex_local_host.js"></script>\n <script src="/__codex_shim.js"></script>\n <script type="module"`,
);
}
return out;
}
const LOCAL_HOST_JS = fs
.readFileSync(path.join(HERE, "local-host.js"), "utf8")
.replace("/*@METHODS@*/ []", JSON.stringify(METHODS));
const SHIM = fs.readFileSync(path.join(HERE, "shim.js"), "utf8");
// ------------------------------------------------------------- mock backend
// Local stand-in for the ChatGPT backend. The app server runs with
// chatgpt_base_url pointed at /mock, so every call that would reach OpenAI's
// cloud — plugin catalog refresh, account endpoints, telemetry — is answered
// from this process with well-formed local JSON. Nothing leaves the machine
// and no caller ever sees a connection error.
const MOCK_LOG = [];
const nowIso = () => new Date().toISOString();
/** Valid, empty Statsig initialize payload — every gate resolves to "off". */
const statsigBootstrapPayload = (stableId = "codex-web-local") => ({
has_updates: true,
hash_used: "djb2",
time: Date.now(),
feature_gates: {},
dynamic_configs: {},
layer_configs: {},
param_stores: {},
user: {
userID: null,
email: null,
custom: { auth_method: null },
customIDs: { stableID: stableId, WebAnonymousCookieID: stableId, DeviceId: stableId },
},
});
/** A remote-catalog plugin descriptor the renderer can render without data. */
const mockRemotePluginRead = (pluginName) => ({
plugin: {
summary: {
id: pluginName,
name: String(pluginName).replace(/^plugin_[^_]*_/, ""),
remotePluginId: pluginName,
version: null,
installed: false,
enabled: false,
installPolicy: "NOT_AVAILABLE",
installPolicySource: null,
mustShowInstallationInterstitial: false,
authPolicy: "ON_USE",
availability: "NOT_AVAILABLE",
disabledReason: "unavailable_offline",
interface: {
displayName: pluginName,
shortDescription: "Cloud plugin (offline build)",
longDescription: "This plugin belongs to the ChatGPT cloud catalog and is not available in a fully local build.",
developerName: null,
category: null,
capabilities: [],
websiteUrl: null,
privacyPolicyUrl: null,
termsOfServiceUrl: null,
defaultPrompt: [],
brandColor: "#6B7280",
composerIcon: null,
composerIconUrl: null,
logo: null,
logoDark: null,
logoUrl: null,
logoUrlDark: null,
screenshots: [],
screenshotUrls: [],
},
keywords: [],
},
skills: [],
onboardingSkill: null,
},
});
const serveMock = (req, res, urlPath) => {
let search = "";
try {
search = new URL(req.url, "http://localhost").search;
} catch {
/* keep empty */
}
MOCK_LOG.push(`${req.method} ${urlPath}${search}`);
if (MOCK_LOG.length > 200) MOCK_LOG.shift();
const json = (obj, status = 200) => {
res.writeHead(status, { "content-type": MIME[".json"], "cache-control": "no-store" });
res.end(JSON.stringify(obj));
};
const p = urlPath.replace(/^\/mock/, "") || "/";
// Cloud MCP endpoint (ChatGPT-hosted app connectors). Answer 404 so the app
// server marks these connectors unavailable immediately — with a semantically
// empty 200 it would retry and block thread startup on MCP enumeration.
if (/^\/api\/codex\/ps\/mcp/.test(p)) {
res.writeHead(404, { "content-type": MIME[".json"], "cache-control": "no-store" });
res.end(JSON.stringify({ error: { message: "cloud connectors are unavailable in a local build" } }));
return;
}
// ---- known wham (ChatGPT backend) shapes -------------------------------
// Cloud tasks: the renderer dereferences `.items` and paginates on `.cursor`.
if (/^\/wham\/tasks\/list/.test(p)) {
json({ items: [], cursor: null });
return;
}
// Statsig bootstrap: `{ statsigPayload: <string> }`, exactly as the desktop
// main process hands it over.
if (/^\/wham\/statsig\/bootstrap/.test(p)) {
json({ statsigPayload: JSON.stringify(statsigBootstrapPayload()) });
return;
}
// Plugin catalog refresh: the app server persists this under
// ~/.codex/cache/remote_plugin_catalog. The truthful local answer is an
// empty catalog — no cloud plugins exist in this build.
if (/plugin/i.test(p)) {
json({ schema_version: 1, fetched_at: nowIso(), plugins: [] });
return;
}
// Generic empty-but-valid JSON: callers parse it, find nothing to do, and
// move on without errors.
json({});
};
// ------------------------------------------------------- mock model provider
// The model wire lives in ./mock-model.mjs so the local model is defined once
// and every caller gets the identical Responses-API stream.
const logMockRequest = (line) => {
MOCK_LOG.push(line);
if (MOCK_LOG.length > 200) MOCK_LOG.shift();
};
// ------------------------------------------------------------------- server
// Read-only git ops accepted by POST /__codex_git (A7). Anything else is
// rejected; there is intentionally no write op in this set.
const GIT_OPS = new Set(["stable-metadata", "branch", "status"]);
const readJsonBody = (req) =>
new Promise((resolve, reject) => {
let raw = "";
req.setEncoding("utf8");
req.on("data", (c) => {
raw += c;
if (raw.length > 4_000_000) reject(new Error("body too large"));
});
req.on("end", () => {
try {
resolve(raw ? JSON.parse(raw) : {});
} catch (err) {
reject(err);
}
});
req.on("error", reject);
});
const server = http.createServer(async (req, res) => {
let urlPath;
try {
urlPath = decodeURIComponent(new URL(req.url, "http://localhost").pathname);
} catch {
res.writeHead(400).end("bad request");
return;
}
if (urlPath === "/__codex_shim.js") {
res.writeHead(200, { "content-type": MIME[".js"], "cache-control": "no-store" });
res.end(SHIM);
return;
}
if (urlPath === "/__codex_local_host.js") {
res.writeHead(200, { "content-type": MIME[".js"], "cache-control": "no-store" });
res.end(LOCAL_HOST_JS);
return;
}
// Filesystem work the Electron main process would do: allocate a directory for
// a conversation that is not attached to a project.
if (urlPath === "/__codex_projectless") {
if (req.method !== "POST") {
res.writeHead(405).end("method not allowed");
return;
}
try {
const { op, args } = await readJsonBody(req);
const result = op === "readWorkspaceRoot" ? readWorkspaceRoot() : createProjectlessPaths(args ?? {});
res.writeHead(200, { "content-type": MIME[".json"], "cache-control": "no-store" });
res.end(JSON.stringify({ result }));
} catch (err) {
res.writeHead(200, { "content-type": MIME[".json"], "cache-control": "no-store" });
res.end(JSON.stringify({ error: { message: err?.message ?? "workspace error" } }));
}
return;
}
// Read-only git (A7). The renderer gates the composer on
// `git.request("stable-metadata")`, which the desktop main process answers.
// Only three read-only ops are accepted; write operations cannot be
// expressed, and no caller-supplied path ever reaches argv — `cwd` is
// passed to execFile as the working-directory option only, and every git
// invocation runs strictly inside that directory. A missing cwd or a
// non-repository cwd gets a structured error, not a silent stub.
if (urlPath === "/__codex_git") {
if (req.method !== "POST") {
res.writeHead(405).end("method not allowed");
return;
}
const gitError = (message) => {
res.writeHead(200, { "content-type": MIME[".json"], "cache-control": "no-store" });
res.end(JSON.stringify({ error: { message } }));
};
try {
const { op, cwd } = await readJsonBody(req);
if (!GIT_OPS.has(op)) {
gitError(`unsupported git op: ${String(op)}`);
return;
}
if (typeof cwd !== "string" || !cwd) {
gitError("git request needs a cwd");
return;
}
const dir = path.resolve(cwd);
let st;
try {
st = fs.statSync(dir);
} catch {
gitError(`cwd does not exist: ${dir}`);
return;
}
if (!st.isDirectory()) {
gitError(`cwd is not a directory: ${dir}`);
return;
}
const runGit = (args) => execFileSync("git", args, { cwd: dir, encoding: "utf8", timeout: 10_000 }).trim();
let toplevel;
try {
toplevel = runGit(["rev-parse", "--show-toplevel"]);
} catch {
gitError(`not a git repository: ${dir}`);
return;
}
let branch = null;
try {
const raw = runGit(["rev-parse", "--abbrev-ref", "HEAD"]);
branch = raw === "HEAD" ? null : raw; // detached HEAD, or no commits yet
} catch {
branch = null;
}
let upstream = null;
try {
upstream = runGit(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]);
} catch {
/* no upstream configured */
}
const porcelain = runGit(["status", "--porcelain=v1"]);
const clean = porcelain === "";
// stable-metadata keeps the six fields the old stub returned, now
// populated for real; branch/status return the subset they need.
const result =
op === "branch"
? { branch, upstream }
: op === "status"
? { branch, upstream, clean, files: clean ? [] : porcelain.split("\n") }
: {
available: true,
branch,
upstream,
repository: toplevel,
state: clean ? "clean" : "dirty",
worktree: toplevel,
};
res.writeHead(200, { "content-type": MIME[".json"], "cache-control": "no-store" });
res.end(JSON.stringify({ result }));
} catch (err) {
gitError(err?.message ?? "git error");
}
return;
}
// Diagnostic: what has the app server asked the mock backend for?
if (urlPath === "/__codex_mocklog") {
res.writeHead(200, { "content-type": MIME[".json"], "cache-control": "no-store" });
res.end(JSON.stringify({ requests: MOCK_LOG }));
return;
}
// Statsig bootstrap. The renderer normally POSTs /wham/statsig/bootstrap to
// the ChatGPT backend; the `statsig-bootstrap-local` patch redirects it here.
// The response is a valid, empty initialize payload — every gate resolves to
// "off" and the SDK completes initialization instead of retrying forever.
if (urlPath === "/__codex_statsig_bootstrap") {
MOCK_LOG.push(`POST ${urlPath} (statsig bootstrap)`);
if (MOCK_LOG.length > 200) MOCK_LOG.shift();
if (req.method !== "POST") {
res.writeHead(405).end("method not allowed");
return;
}
let body = {};
try {
body = await readJsonBody(req);
} catch {
/* empty request body is fine */
}
const stableId = typeof body.stable_id === "string" ? body.stable_id : "codex-web-local";
res.writeHead(200, { "content-type": MIME[".json"], "cache-control": "no-store" });
// The renderer expects `{ statsigPayload: <string> }` — the payload as a
// JSON string, exactly as the desktop main process hands it over.
res.end(JSON.stringify({ statsigPayload: JSON.stringify(statsigBootstrapPayload(stableId)) }));
return;
}
// Same-origin Statsig event flush: the renderer posts to /ces/v1/rgstr, which
// lands here. Accept and drop — nothing is measured, nothing leaves.
if (urlPath.startsWith("/ces/")) {
res.writeHead(202, { "content-type": MIME[".json"], "cache-control": "no-store" });
res.end("{}");
return;
}
// Mock model provider (see ./mock-model.mjs). The app server's
// model_provider points here, so `turn/start` completions are answered from
// this process with a well-formed Responses-API SSE stream. Nothing reaches
// the network and the reply renders exactly as a live model's would.
if (urlPath === "/mockrelay" || urlPath.startsWith("/mockrelay/")) {
serveMockRelay(req, res, urlPath, { onRequest: logMockRequest });
return;
}
// Mock ChatGPT backend (see serveMock above).
if (urlPath === "/mock" || urlPath.startsWith("/mock/")) {
serveMock(req, res, urlPath);
return;
}
// Event stream. Everything the app server emits on its own initiative —
// streaming deltas, turn progress, server-initiated approval requests — comes
// back through here. Long-polled so the renderer stays in step without a
// websocket.
if (urlPath === "/__codex_events") {
try {
await bridge.ensure();
const messages = await bridge.waitForNotifications(15_000);
res.writeHead(200, { "content-type": MIME[".json"], "cache-control": "no-store" });
res.end(JSON.stringify({ events: messages }));
} catch (err) {
res.writeHead(200, { "content-type": MIME[".json"], "cache-control": "no-store" });
res.end(JSON.stringify({ events: [], error: String(err?.message ?? err) }));
}
return;
}
// Local JSON-RPC passthrough to the real codex app server.
if (urlPath === "/__codex_rpc") {
if (req.method !== "POST") {
res.writeHead(405).end("method not allowed");
return;
}
let method, params;
try {
({ method, params } = await readJsonBody(req));
if (typeof method !== "string") {
res.writeHead(400, { "content-type": MIME[".json"] }).end('{"error":{"message":"missing method"}}');
return;
}
// The desktop main process strips `originator` filtering before it
// forwards `thread/list` to the app server — a local app-server rejects
// the parameter with -32602. Mirror the sanitisation here.
if (method === "thread/list" && params && typeof params === "object") {
const strip = (obj) => {
if (!obj || typeof obj !== "object") return;
for (const key of Object.keys(obj)) {
if (key === "originator" || key === "originators") delete obj[key];
else if (obj[key] && typeof obj[key] === "object") strip(obj[key]);
}
};
strip(params);
}
await bridge.ensure();
const result = await bridge.request(method, params);
res.writeHead(200, { "content-type": MIME[".json"], "cache-control": "no-store" });
res.end(JSON.stringify({ result: result ?? null }));
} catch (err) {
// A cloud-catalog plugin cannot be fetched in a local build; answer with
// a well-formed offline descriptor instead of an error the renderer
// would surface as a toast.
if (method === "plugin/read" && params && typeof params.pluginName === "string") {
res.writeHead(200, { "content-type": MIME[".json"], "cache-control": "no-store" });
res.end(JSON.stringify({ result: mockRemotePluginRead(params.pluginName) }));
return;
}
const message = err?.rpc?.message ?? err?.message ?? "rpc failed";
res.writeHead(200, { "content-type": MIME[".json"], "cache-control": "no-store" });
res.end(JSON.stringify({ error: { code: err?.rpc?.code ?? -32000, message } }));
}
return;
}
const rel = urlPath === "/" ? "index.html" : urlPath.replace(/^\/+/, "");
const abs = path.join(ROOT, rel);
if (!abs.startsWith(ROOT)) {
res.writeHead(403).end("forbidden");
return;
}
let file = abs;
if (!fs.existsSync(file) || fs.statSync(file).isDirectory()) {
file = path.join(ROOT, "index.html"); // SPA fallback
if (!fs.existsSync(file)) {
res.writeHead(404).end("not found");
return;
}
}
const ext = path.extname(file).toLowerCase();
const type = MIME[ext] || "application/octet-stream";
try {
let body = fs.readFileSync(file);
let patched = null;
if (ext === ".html") {
body = Buffer.from(patchHtml(body.toString("utf8")), "utf8");
} else if (ext === ".js") {
const { out, applied } = patchJs(body.toString("utf8"));
if (applied.length) {
body = Buffer.from(out, "utf8");
patched = applied.join(",");
}
}
res.writeHead(200, {
"content-type": type,
"cache-control": "no-store",
...(patched ? { "x-codex-patched": patched } : {}),
});
res.end(body);
} catch (err) {
console.error("[web] read error", file, err.message);
res.writeHead(500).end("read error");
}
});
server.listen(PORT, "127.0.0.1", () => {
console.log(`[web] Codex webview served from ${ROOT}`);
console.log(`[web] http://127.0.0.1:${PORT}/`);
console.log(`[web] app server: ${bridge.binary ?? "not found"}`);
console.log(`[web] workspace root: ${WORKSPACE_ROOT}`);
console.log(`[web] mock backend: http://127.0.0.1:${PORT}/mock`);
});
// A long-running local host must survive its own bugs: log loudly, keep
// serving. Without this one bad request would take the whole app down.
process.on("uncaughtException", (err) => {
console.error("[web] uncaught:", err?.stack ?? err);
});
process.on("unhandledRejection", (err) => {
console.error("[web] unhandled rejection:", err?.stack ?? err);
});
+20
View File
@@ -0,0 +1,20 @@
#!/usr/bin/env bash
# Start the standalone original-UI web host in development mode.
#
# ./start.sh UI :8095
# UI_PORT=9000 ./start.sh custom port
# node verify.mjs --deep run the self-check in another shell
#
# The app-server binary ships inside ChatGPT.app; set CODEX_APP_SERVER_BIN
# if it lives elsewhere. There is no orchestrator/API layer in this stack.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
UI_PORT="${UI_PORT:-8095}"
export CODEX_WEB_PORT="$UI_PORT"
echo "[stack] UI http://127.0.0.1:${UI_PORT}/"
echo "[stack] test node ${ROOT}/verify.mjs --deep"
exec node "$ROOT/host/web-server.mjs" --port "$UI_PORT"
+160
View File
@@ -0,0 +1,160 @@
#!/usr/bin/env node
/**
* Self-check for the standalone original-UI web host. Run while ./start.sh is up.
* node verify.mjs served assets + HTTP surface
* node verify.mjs --deep also drive a real app-server RPC through the UI host
*
* The orchestrator/API layer is intentionally not part of this stack; nothing
* here probes port 8899. Everything the UI needs is served by host/web-server.mjs
* on UI_PORT, including the local JSON-RPC passthrough and the mock backends.
*/
import http from "node:http";
const uiPort = Number(process.env.UI_PORT || 8095);
const deep = process.argv.includes("--deep");
function request(path, { method = "GET", headers = {}, body = null, timeout = 8000 } = {}) {
return new Promise((resolve) => {
const req = http.request({ host: "127.0.0.1", port: uiPort, path, method, headers }, (res) => {
const chunks = [];
res.on("data", (c) => chunks.push(c));
res.on("end", () => resolve({
status: res.statusCode,
body: Buffer.concat(chunks).toString("utf8"),
}));
});
req.setTimeout(timeout, () => req.destroy(new Error("timeout")));
req.on("error", (e) => resolve({ status: 0, body: String(e.message) }));
if (body) req.write(body);
req.end();
});
}
function json(r) {
try { return JSON.parse(r.body); } catch { return null; }
}
const results = [];
async function check(name, fn) {
let out;
try {
out = await fn();
} catch (e) {
out = { ok: false, detail: String(e.message) };
}
results.push({ name, ...out });
console.log(`${out.ok ? "PASS" : "FAIL"} ${name} ${out.detail ?? ""}`);
}
const post = (path, payload, timeout = 8000) => request(path, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(payload),
timeout,
});
await check("UI GET / 注入两个宿主脚本", async () => {
const r = await request("/");
const ok = r.status === 200
&& r.body.includes("/__codex_local_host.js")
&& r.body.includes("/__codex_shim.js");
return { ok, detail: `HTTP ${r.status}, ${r.body.length}B` };
});
await check("UI GET /__codex_local_host.js", async () => {
const r = await request("/__codex_local_host.js");
return {
ok: r.status === 200 && r.body.includes("__codexLocalHost"),
detail: `HTTP ${r.status}, ${r.body.length}B`,
};
});
await check("UI GET /__codex_shim.js", async () => {
const r = await request("/__codex_shim.js");
return { ok: r.status === 200 && r.body.length > 1000, detail: `HTTP ${r.status}, ${r.body.length}B` };
});
await check("UI 响应层补丁命中 connect-app-host", async () => {
const r = await request("/assets/connect-app-host-90a50ca9e4b4.js");
return {
ok: r.status === 200 && r.body.includes("__codexLocalHost"),
detail: `HTTP ${r.status}, ${r.body.length}B`,
};
});
await check("UI 原版入口 chunk 可加载", async () => {
const r = await request("/assets/index-fcd3fcef6840.js");
return { ok: r.status === 200 && r.body.length > 100, detail: `HTTP ${r.status}, ${r.body.length}B` };
});
await check("UI GET /ces/v1/rgstr 遥测丢弃", async () => {
const r = await request("/ces/v1/rgstr");
return { ok: r.status === 202, detail: `HTTP ${r.status}` };
});
await check("UI GET /__codex_rpc 拒绝非 POST", async () => {
const r = await request("/__codex_rpc");
return { ok: r.status === 405, detail: `HTTP ${r.status}` };
});
await check("UI POST /__codex_projectless 工作区根", async () => {
const r = await post("/__codex_projectless", { op: "readWorkspaceRoot" });
const j = json(r);
return {
ok: r.status === 200 && typeof j?.result?.workspaceRoot === "string",
detail: `HTTP ${r.status}, ${j?.result?.workspaceRoot ?? r.body.slice(0, 80)}`,
};
});
await check("UI POST /__codex_statsig_bootstrap 空配置", async () => {
const r = await post("/__codex_statsig_bootstrap", { stable_id: "verify" });
const j = json(r);
const gates = json({ body: j?.statsigPayload ?? "" })?.feature_gates;
return {
ok: r.status === 200 && gates && Object.keys(gates).length === 0,
detail: `HTTP ${r.status}, ${r.body.length}B`,
};
});
await check("UI GET /mock/wham/tasks/list 云端替身", async () => {
const r = await request("/mock/wham/tasks/list");
const j = json(r);
return {
ok: r.status === 200 && Array.isArray(j?.items) && j.items.length === 0,
detail: `HTTP ${r.status}, ${r.body.trim()}`,
};
});
await check("UI GET /__codex_mocklog 诊断日志", async () => {
const r = await request("/__codex_mocklog");
const j = json(r);
return {
ok: r.status === 200 && Array.isArray(j?.requests),
detail: `HTTP ${r.status}, ${j?.requests?.length ?? 0} 条`,
};
});
if (deep) {
await check("UI POST /__codex_rpc -> 真实 app-server thread/list", async () => {
const r = await post("/__codex_rpc", { method: "thread/list", params: { limit: 1 } }, 45000);
const j = json(r);
const ok = r.status === 200 && j && !j.error && j.result != null;
return { ok: !!ok, detail: `HTTP ${r.status}, ${r.body.slice(0, 140)}` };
});
await check("UI POST /__codex_rpc -> config/read 含 relay 覆盖", async () => {
const r = await post("/__codex_rpc", { method: "config/read", params: {} }, 45000);
const j = json(r);
const model = j?.result?.config?.model;
return {
ok: r.status === 200 && typeof model === "string" && model.length > 0,
detail: `HTTP ${r.status}, model=${model ?? "n/a"}`,
};
});
}
const failed = results.filter((r) => !r.ok);
console.log(`\n${results.length - failed.length}/${results.length} passed`);
console.log("orchestrator 已移除;本栈只有 :8095 一个服务,无 :8899 API。");
if (!deep) console.log("shallow run: pass --deep to exercise the real app-server RPC");
process.exit(failed.length ? 1 : 0);
+7 -2
View File
@@ -29,14 +29,19 @@ export const paths = {
environmentsCreate: "/environments",
environmentArchive: (id: string) => "/environments/" + enc(id) + "/archive",
tasks: (pid: string) => "/tasks?project_id=" + enc(pid),
tasksPage: (pid: string, limit: number, offset: number, status?: string, executor?: string, q?: string) => {
tasksPage: (pid: string, limit: number, offset: number, status?: string, executor?: string, q?: string, kind?: string) => {
let u = "/tasks?project_id=" + enc(pid) + "&limit=" + limit + "&offset=" + offset + "&order=desc";
if (status) u += "&status=" + enc(status);
if (executor) u += "&executor=" + enc(executor);
if (q && q.trim()) u += "&q=" + enc(q.trim());
if (kind) u += "&kind=" + enc(kind);
return u;
},
tasksByParent: (pid: string, parent: string, kind?: string) => {
let u = "/tasks?project_id=" + enc(pid) + "&parent_task_id=" + enc(parent);
if (kind) u += "&kind=" + enc(kind);
return u;
},
tasksByParent: (pid: string, parent: string) => "/tasks?project_id=" + enc(pid) + "&parent_task_id=" + enc(parent),
tasksCreate: "/tasks",
task: (id: string) => "/tasks/" + enc(id),
taskAction: (id: string, kind: string) => "/tasks/" + enc(id) + "/" + kind,
+5
View File
@@ -19,6 +19,9 @@ export interface Task {
turn_id?: string; root_turn_id?: string; plan?: number | boolean; forked_from?: string;
coordinator_parts?: Array<{ name: string; prompt?: string }> | null; parent_task_id?: string;
coordinator_part?: string; goal_mode?: number | boolean; verify_command?: string;
// 旁聊(Muse 角色对齐 P2):服务端 _task_to_dict 回 kind,
// 缺列/NULL 的老行按 "main" 计。
kind?: "main" | "side";
no_time_limit?: number | boolean;
environment_id?: string; attachments?: string[]; model?: string; effort?: string;
child_model?: string; child_effort?: string; approval?: string;
@@ -71,6 +74,8 @@ export interface DispatchBody {
model?: string; effort?: string; child_model?: string; child_effort?: string;
approval?: string; plan?: number; goal_mode?: number; no_time_limit?: number; upload_id?: string;
coordinator_parts?: Array<{ name: string; prompt?: string }>;
// 旁聊(Muse 角色对齐 P2):kind="side" 必须配 parent_task_id。
kind?: "side"; parent_task_id?: string;
}
export interface ContinueBody {
message: string; model?: string; effort?: string; child_model?: string;
+3
View File
@@ -21,6 +21,7 @@ export interface ConsoleCtx {
models: string[]; modelCustomVisible: boolean;
tasks: Task[]; taskCountText: string; taskError: string; refreshTasks: () => void;
statusFilter: string; setStatusFilter: (v: string) => void; executorFilter: string; setExecutorFilter: (v: string) => void; searchText: string; setSearchText: (v: string) => void;
kindFilter: string; setKindFilter: (v: string) => void;
filteredTasks: Task[];
taskPage: number; taskTotalPages: number; taskTotal: number; taskStatusCounts: Record<string, number>; goToTaskPage: (p: number) => void;
currentTask: Task | null; detailBody: string; actionMsg: string; actionReasons: string;
@@ -28,6 +29,8 @@ export interface ConsoleCtx {
forkVisible: boolean; setForkVisible: (v: boolean) => void; forkTask: (msg: string) => Promise<void>; forkMsg: string;
// Continue input is ContinuePanel (ChatInput); its send path is sendChatMessage below.
coordChildren: Task[]; coordText: string; agents: Agent[]; agentsNote: string;
// 旁聊(Muse 角色对齐 P2):当前任务的旁聊列表 + 派发器旁聊模式的父任务。
sideThreads: Task[]; sideParentId: string | null; setSideParentId: (v: string | null) => void;
reviews: Review[]; reviewMsg: string; reviewError: string; refreshReviews: () => void; postReview: (body: ReviewBody) => Promise<void>; submitFindings: (id: string) => void;
traceEntries: TraceEntry[]; traceNote: string; traceDone: string; traceKind: string; setTraceKind: (v: string) => void; downloadTrace: () => void; traceMsg: string;
sendChatMessage: (text: string) => Promise<void>; chatMsg: string;
@@ -23,6 +23,10 @@ export function useConsoleStore(): ConsoleCtx {
const onTasksReload = useCallback(() => { void taskList.refreshTasks(undefined, projectIdRef.current); }, [taskList]);
const detail = useTaskDetail(getRole, onTasksReload);
const [refreshStamp, setRefreshStamp] = useState("--:--:--");
// 旁聊(Muse 角色对齐 P2):派发器的旁聊父任务。列表“开旁聊”
// 点选主聊后填这里,派发器读到即进旁聊模式;提交成功或点
// “取消旁聊模式”后清空。放胶水层,不进 App.tsx。
const [sideParentId, setSideParentId] = useState<string | null>(null);
const selectProject = useCallback((id: string) => {
catalog.setProjectId(id);
@@ -126,6 +130,7 @@ export function useConsoleStore(): ConsoleCtx {
refreshTasks: () => void taskList.refreshTasks(undefined, projectIdRef.current),
statusFilter: taskList.statusFilter, setStatusFilter: taskList.setStatusFilter,
executorFilter: taskList.executorFilter, setExecutorFilter: taskList.setExecutorFilter,
kindFilter: taskList.kindFilter, setKindFilter: taskList.setKindFilter,
searchText: taskList.searchText, setSearchText: taskList.setSearchText,
filteredTasks: taskList.filteredTasks,
taskPage: taskList.page, taskTotalPages: taskList.totalPages, taskTotal: taskList.taskTotal,
@@ -135,6 +140,7 @@ export function useConsoleStore(): ConsoleCtx {
doAction: detail.doAction, taskCost: detail.taskCost, costBox: detail.costBox, costSummary: detail.costSummary,
forkVisible: detail.forkVisible, setForkVisible: detail.setForkVisible, forkTask: detail.forkTask, forkMsg: detail.forkMsg,
coordChildren: detail.coordChildren, coordText: "", agents: detail.agents, agentsNote: detail.agentsNote,
sideThreads: detail.sideThreads, sideParentId, setSideParentId,
reviews: detail.reviews, reviewMsg: detail.reviewMsg, reviewError: detail.reviewError,
refreshReviews: detail.refreshReviews, postReview: detail.postReview, submitFindings: detail.submitFindings,
traceEntries: detail.traceEntries, traceNote: detail.traceNote, traceDone: detail.traceDone,
+15 -3
View File
@@ -19,6 +19,9 @@ export function useTaskDetail(getRole: (pid?: string) => string | null, onTasksR
const [forkVisible, setForkVisible] = useState(false);
const [forkMsg, setForkMsg] = useState("");
const [coordChildren, setCoordChildren] = useState<Task[]>([]);
// 旁聊(Muse 角色对齐 P2):当前任务挂着的旁聊线程(kind=side
// 且 parent_task_id=本任务),与协调员子任务同款拉取方式。
const [sideThreads, setSideThreads] = useState<Task[]>([]);
const [agents, setAgents] = useState<Agent[]>([]);
const [agentsNote, setAgentsNote] = useState("");
const [reviews, setReviews] = useState<Review[]>([]);
@@ -71,6 +74,13 @@ export function useTaskDetail(getRole: (pid?: string) => string | null, onTasksR
} catch { setCoordChildren([]); }
}, []);
const loadSideThreads = useCallback(async (task: Task) => {
try {
const d = await api<{ tasks: Task[] }>(paths.tasksByParent(task.project_id || "", task.id, "side"));
setSideThreads(d.tasks || []);
} catch { setSideThreads([]); }
}, []);
const loadCost = useCallback(async (taskId: string) => {
try {
const c = await api<TaskCost>(paths.taskCost(taskId));
@@ -248,6 +258,7 @@ export function useTaskDetail(getRole: (pid?: string) => string | null, onTasksR
void startTrace(taskId, t.status);
void loadAgents(taskId);
void loadCoordChildren(t);
void loadSideThreads(t);
void loadChatHistory(taskId);
void loadFollowups(taskId);
void loadPeerMessages(taskId);
@@ -259,7 +270,7 @@ export function useTaskDetail(getRole: (pid?: string) => string | null, onTasksR
setCurrentTask(null);
setDetailBody("加载失败:" + e.message);
}
}, [stopTrace, startTrace, loadAgents, loadCoordChildren, loadChatHistory, loadFollowups, loadPeerMessages, loadThread, loadThreads, loadReviews, loadApprovals, loadCost, getRole]);
}, [stopTrace, startTrace, loadAgents, loadCoordChildren, loadSideThreads, loadChatHistory, loadFollowups, loadPeerMessages, loadThread, loadThreads, loadReviews, loadApprovals, loadCost, getRole]);
const refreshDetailLists = useCallback(async () => {
const t = currentTaskRef.current;
@@ -277,12 +288,13 @@ export function useTaskDetail(getRole: (pid?: string) => string | null, onTasksR
void loadReviews(t.id);
void loadApprovals(t.id);
void loadCoordChildren(t);
void loadSideThreads(t);
void loadAgents(t.id);
void loadPeerMessages(t.id);
void loadThread(t.id);
void loadThreads(t.id);
void loadCost(t.id);
}, [loadFollowups, loadChatHistory, loadReviews, loadApprovals, loadCoordChildren, loadAgents, loadPeerMessages, loadThread, loadThreads, loadCost]);
}, [loadFollowups, loadChatHistory, loadReviews, loadApprovals, loadCoordChildren, loadSideThreads, loadAgents, loadPeerMessages, loadThread, loadThreads, loadCost]);
const doAction = useCallback(async (kind: string) => {
const t = currentTaskRef.current;
@@ -423,7 +435,7 @@ export function useTaskDetail(getRole: (pid?: string) => string | null, onTasksR
return {
currentTask, detailBody, actionMsg, actionReasons, costBox, costSummary, forkVisible, setForkVisible, forkMsg,
coordChildren, agents, agentsNote, reviews, reviewMsg, reviewError, traceEntries, traceNote, traceDone,
coordChildren, sideThreads, agents, agentsNote, reviews, reviewMsg, reviewError, traceEntries, traceNote, traceDone,
traceKind, setTraceKind, traceMsg, followups, approvals, approvalMsg, peerMessages, peerMsg,
threadText, threadGateNote, threadMsg, threadList, chatMsg, chatHistory,
showDetail, refreshDetailLists, doAction, taskCost, forkTask, postReview, submitFindings,
+7 -4
View File
@@ -21,13 +21,14 @@ export function useTaskList() {
const [taskError, setTaskError] = useState("");
const [statusFilter, setStatusFilter] = useState("");
const [executorFilter, setExecutorFilter] = useState("");
const [kindFilter, setKindFilter] = useState("");
const [searchText, setSearchText] = useState("");
const [page, setPageState] = useState(0);
const [taskTotal, setTaskTotal] = useState(0);
const [taskStatusCounts, setTaskStatusCounts] = useState<Record<string, number>>({});
const pageRef = useRef(0);
const filterRef = useRef({ status: "", executor: "", q: "" });
filterRef.current = { status: statusFilter, executor: executorFilter, q: searchText };
const filterRef = useRef({ status: "", executor: "", q: "", kind: "" });
filterRef.current = { status: statusFilter, executor: executorFilter, q: searchText, kind: kindFilter };
const lastProjectRef = useRef("");
const refreshTasks = useCallback(async (pid?: string, currentPid?: string, pageOverride?: number) => {
@@ -38,7 +39,7 @@ export function useTaskList() {
const f = filterRef.current;
try {
const d = await api<{ tasks: Task[]; total?: number; status_counts?: Record<string, number> }>(
paths.tasksPage(id, TASK_PAGE_SIZE, p * TASK_PAGE_SIZE, f.status, f.executor, f.q));
paths.tasksPage(id, TASK_PAGE_SIZE, p * TASK_PAGE_SIZE, f.status, f.executor, f.q, f.kind));
setTasks(d.tasks || []);
setTaskTotal(d.total ?? (d.tasks || []).length);
setTaskStatusCounts(d.status_counts || {});
@@ -66,13 +67,14 @@ export function useTaskList() {
const t = setTimeout(() => { void refreshTasks(lastProjectRef.current, undefined, 0); }, 300);
return () => clearTimeout(t);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [statusFilter, executorFilter, searchText]);
}, [statusFilter, executorFilter, searchText, kindFilter]);
// Filter/project changes reset to page 1: the old page may not
// exist under the new filter, and staying on it would show an
// empty page with a misleading "共 N 个" count.
const setStatusFilterPage = useCallback((v: string) => { setStatusFilter(v); setPage(0); }, [setPage]);
const setExecutorFilterPage = useCallback((v: string) => { setExecutorFilter(v); setPage(0); }, [setPage]);
const setKindFilterPage = useCallback((v: string) => { setKindFilter(v); setPage(0); }, [setPage]);
const setSearchTextPage = useCallback((v: string) => { setSearchText(v); setPage(0); }, [setPage]);
const refreshTasksFirstPage = useCallback(async (pid?: string) => {
setPage(0);
@@ -99,6 +101,7 @@ export function useTaskList() {
return {
tasks, taskError, statusFilter, setStatusFilter: setStatusFilterPage,
executorFilter, setExecutorFilter: setExecutorFilterPage,
kindFilter, setKindFilter: setKindFilterPage,
searchText, setSearchText: setSearchTextPage,
filteredTasks, taskCountText, refreshTasks, refreshTasksFirstPage,
page, totalPages, taskTotal, taskStatusCounts, goToPage, dispatch,
@@ -1,17 +1,22 @@
import { useRef, useState } from "react";
import { useConsole } from "../context";
import { Card, Bar } from "../components/ui";
import { gatherAttachments } from "../api/client";
import { gatherAttachments, shortId } from "../api/client";
import { EFFORTS } from "../api/types";
export default function DispatchSection() {
const ctx = useConsole();
// 旁聊模式(Muse 角色对齐 P2):列表“开旁聊”点选主聊后,
// sideParentId 即父任务 id;此时隐藏环境/协调员/附件,
// 执行器默认不指定(服务端旁聊默认 local),提交带
// kind="side" + parent_task_id。
const isSide = !!ctx.sideParentId;
const [prompt, setPrompt] = useState("");
const [coordParts, setCoordParts] = useState("");
const [goalMode, setGoalMode] = useState(false);
const [goalInvincible, setGoalInvincible] = useState(false);
const [planMode, setPlanMode] = useState(false);
const [executor, setExecutor] = useState("docker");
const [sideExecutor, setSideExecutor] = useState("");
const [model, setModel] = useState("");
const [modelCustom, setModelCustom] = useState("");
const [effort, setEffort] = useState("");
@@ -29,8 +34,15 @@ export default function DispatchSection() {
if (!ctx.projectId) { setMsg("先选项目"); return; }
if (!prompt.trim()) { setMsg("prompt 必填"); return; }
const body: any = { project_id: ctx.projectId, prompt: prompt.trim() };
if (ctx.envSelect) body.environment_id = ctx.envSelect;
if (executor) body.executor = executor;
if (isSide) {
body.kind = "side";
body.parent_task_id = ctx.sideParentId;
// 旁聊默认不指定执行器(服务端填 local);显式选了才送。
if (sideExecutor) body.executor = sideExecutor;
} else {
if (ctx.envSelect) body.environment_id = ctx.envSelect;
if (executor) body.executor = executor;
}
const m = modelCustom.trim() || model;
if (m) body.model = m;
if (effort) body.effort = effort;
@@ -45,24 +57,28 @@ export default function DispatchSection() {
}
if (planMode) {
if (approval === "full") { setMsg("计划模式不能与完全访问同用"); return; }
if (goalMode || goalInvincible) { setMsg("计划模式不能与 goal 模式同用"); return; }
if (goalMode) { setMsg("计划模式不能与 goal 模式同用"); return; }
body.plan = 1;
}
// 无敌版 = goal 模式 + 任务不限时:勾了它就同时发 goal_mode 与
// no_time_limit(服务端已支持该字段);与普通 goal 勾选不冲突,
// 无敌版优先,单独勾普通 goal 时行为不变。
if (goalInvincible) { body.goal_mode = 1; body.no_time_limit = 1; }
else if (goalMode) body.goal_mode = 1;
// 唯一 goal 模式 = 原无敌版:勾选即同时发 goal_mode 与
// no_time_limit(任务不限时);普通 goal 入口已取消,不再
// 单独发不带 no_time_limit 的 goal_mode。
if (goalMode) { body.goal_mode = 1; body.no_time_limit = 1; }
const coordRaw = coordParts.trim();
if (coordRaw) {
if (coordRaw && !isSide) {
try { body.coordinator_parts = JSON.parse(coordRaw); } catch { setMsg("协调员子任务 JSON 解析失败"); return; }
if (!Array.isArray(body.coordinator_parts)) { setMsg("协调员子任务必须是数组"); return; }
if (goalMode || goalInvincible || planMode) { setMsg("协调员任务不能与 goal/计划模式同用:协调员任务本身不跑模型回合,请取消 goal/计划勾选,或清空协调员 JSON"); return; }
if (goalMode || planMode) { setMsg("协调员任务不能与 goal/计划模式同用:协调员任务本身不跑模型回合,请取消 goal/计划勾选,或清空协调员 JSON"); return; }
}
const atts = gatherAttachments(filesRef.current?.files || null, dirRef.current?.files || null, imgRef.current?.files || null);
const atts = isSide ? [] : gatherAttachments(filesRef.current?.files || null, dirRef.current?.files || null, imgRef.current?.files || null);
try {
const result = await ctx.dispatch(body, atts);
setMsg(result);
if (isSide) {
setMsg("旁聊已创建(无 worktree)");
ctx.setSideParentId(null);
} else {
setMsg(result);
}
setPrompt(""); setCoordParts("");
if (filesRef.current) filesRef.current.value = "";
if (dirRef.current) dirRef.current.value = "";
@@ -72,21 +88,35 @@ export default function DispatchSection() {
return (
<Card id="nav-newtask" className="composer" title="新建任务">
{isSide ? (
<div className="muted">旁聊模式:挂在父任务 {shortId(ctx.sideParentId)} 下,不建 worktree/分支/容器;终态后结论回写父级。
{" "}<button type="button" onClick={() => ctx.setSideParentId(null)}>取消旁聊模式</button>
</div>
) : null}
<Bar>
<select aria-label="环境" value={ctx.envSelect} onChange={(e) => ctx.setEnvSelect(e.target.value)}>
<option value="">环境:不指定(走项目仓库)</option>
{ctx.envs.map((e) => <option key={e.id} value={e.id} disabled={e.status !== "ready"}>{e.name}({e.status})</option>)}
</select>
<select aria-label="执行器" value={executor} onChange={(e) => setExecutor(e.target.value)}>
<option value="docker">docker(默认,多用户下隔离最强)</option><option value="local">local</option><option value="cloud">cloud</option>
</select>
{isSide ? null : (
<select aria-label="环境" value={ctx.envSelect} onChange={(e) => ctx.setEnvSelect(e.target.value)}>
<option value="">环境:不指定(走项目仓库)</option>
{ctx.envs.map((e) => <option key={e.id} value={e.id} disabled={e.status !== "ready"}>{e.name}({e.status})</option>)}
</select>
)}
{isSide ? (
<select aria-label="执行器" value={sideExecutor} onChange={(e) => setSideExecutor(e.target.value)}>
<option value="">执行器:不指定(默认 local)</option><option value="local">local</option><option value="docker">docker</option><option value="cloud">cloud</option>
</select>
) : (
<select aria-label="执行器" value={executor} onChange={(e) => setExecutor(e.target.value)}>
<option value="docker">docker(默认,多用户下隔离最强)</option><option value="local">local</option><option value="cloud">cloud</option>
</select>
)}
<label><input type="checkbox" checked={goalMode} onChange={(e) => setGoalMode(e.target.checked)} /> goal 模式</label>
<label><input type="checkbox" checked={goalInvincible} onChange={(e) => setGoalInvincible(e.target.checked)} /> goal 模式无敌版</label>
<label><input type="checkbox" checked={planMode} onChange={(e) => setPlanMode(e.target.checked)} /> 计划模式(只读)</label>
</Bar>
<textarea className="prompt stacked" placeholder="任务 prompt(必填)" value={prompt} onChange={(e) => setPrompt(e.target.value)} />
<textarea className="coord stacked" placeholder="协调员子任务 JSON(可选),留空为普通任务" value={coordParts} onChange={(e) => setCoordParts(e.target.value)} />
<div className="muted">协调员格式:[{"{"}"name":"后端","prompt":"做后端"{"}"},{"{"}"name":"前端","prompt":"做前端"{"}"}];提交后 worker 并行建子任务、等齐、合流汇总。不能与 goal/计划模式同用。协调员与 goal/计划互斥:勾了 goal 或计划模式时不要填协调员 JSON,提交前会校验并拦截。子任务费用记父任务创建人账;父任务创建时按分工数预留配额,余额不足时整个父任务被挡下。</div>
{isSide ? null : (<>
<textarea className="coord stacked" placeholder="协调员子任务 JSON(可选),留空为普通任务" value={coordParts} onChange={(e) => setCoordParts(e.target.value)} />
<div className="muted">协调员格式:[{"{"}"name":"后端","prompt":"做后端"{"}"},{"{"}"name":"前端","prompt":"做前端"{"}"}];提交后 worker 并行建子任务、等齐、合流汇总。不能与 goal/计划模式同用。协调员与 goal/计划互斥:勾了 goal 或计划模式时不要填协调员 JSON,提交前会校验并拦截。子任务费用记父任务创建人账;父任务创建时按分工数预留配额,余额不足时整个父任务被挡下。</div>
</>)}
<Bar>
<select aria-label="模型" value={model} onChange={(e) => setModel(e.target.value)}>
<option value="">模型:默认(跟随配置)</option>
@@ -113,14 +143,15 @@ export default function DispatchSection() {
</select>
</Bar>
<div className="muted">子任务模型/强度是派生默认:本任务用 marker task-create 或协调员分工派生的子任务,未显式写模型/强度时用它;块/分工里显式写了则覆盖它。也可以不在这里填、直接在 prompt 里写“子任务用 gpt-6-luna max”,建任务时服务端会解析成同一对字段(任务详情里可核对)。</div>
{goalMode ? <div className="muted">goal 模式:输入框内容即目标,不需要专用 prompt 与验收命令;不限轮次,模型确认达成后在回复末尾输出“目标已达成”即结束,未达成会一直运行,可用取消停止。</div> : null}
{goalInvincible ? <div className="muted">goal 模式无敌版:goal 模式 + 任务不限时(去掉 worker/docker 总超时;单轮 600 秒护栏还在,不限的是任务总时长与总轮次)。服务端默认的自动续跑与断点镜像同时生效。</div> : null}
<Bar>
<span className="muted">附件(放进任务工作区,图片与普通文件不区分):</span>
<input ref={filesRef} type="file" multiple title="文件" />
<input ref={dirRef} type="file" multiple title="文件夹" {...({ webkitdirectory: "" } as any)} />
<input ref={imgRef} type="file" accept="image/*" multiple title="图片" />
</Bar>
{goalMode ? <div className="muted">goal 模式:输入框内容即目标,不需要专用 prompt 与验收命令;不限轮次,模型确认达成后在回复末尾输出“目标已达成”即结束,未达成会一直运行,可用取消停止;任务不限时(去掉 worker/docker 总超时;单轮 600 秒护栏还在)。服务端默认的自动续跑与断点镜像同时生效。</div> : null}
{isSide ? null : (
<Bar>
<span className="muted">附件(放进任务工作区,图片与普通文件不区分):</span>
<input ref={filesRef} type="file" multiple title="文件" />
<input ref={dirRef} type="file" multiple title="文件夹" {...({ webkitdirectory: "" } as any)} />
<input ref={imgRef} type="file" accept="image/*" multiple title="图片" />
</Bar>
)}
<Bar>
<button className="seal" onClick={submit}>提交任务</button>
<span className="muted">{msg || ctx.dispatchMsg}</span>
@@ -7,7 +7,7 @@ import { firstChar, durationOf, tokenOf, fmtCostUsd } from "../api/taskMeta";
import DetailActions from "./detail/DetailActions";
import BrowserPanel from "./detail/BrowserPanel";
import ReviewPanel from "./detail/ReviewPanel";
import { CoordPanel, AgentsPanel, ApprovalPanel, PeerPanel, ThreadPanel } from "./detail/DetailLists";
import { CoordPanel, SideThreadPanel, AgentsPanel, ApprovalPanel, PeerPanel, ThreadPanel } from "./detail/DetailLists";
// Task inspector (redesign 2026-10-30): the old detail stack —
// 概览 + 基本信息 + prompt + 操作 + 协调 + 子代理 + 评审 +
@@ -60,7 +60,7 @@ export default function TaskDetailPanel() {
const fc = firstChar(t);
const cost = ctx.costSummary;
const modeText = t.plan ? "计划模式(只读)"
: t.goal_mode ? (t.no_time_limit ? "goal 模式无敌版(不限时)" : "goal 模式(不限轮次,模型宣告完成)")
: t.goal_mode ? (t.no_time_limit ? "goal 模式(不限轮次,模型宣告完成;任务不限时)" : "goal 模式(不限轮次,模型宣告完成)")
: t.verify_command ? "goal 模式(旧语义,验收命令)" : null;
return (
<div className="detail-stack" id="nav-detail">
@@ -134,9 +134,11 @@ export default function TaskDetailPanel() {
<div><dt>权限</dt><dd>{t.approval || "默认"}</dd></div>
<div><dt>项目</dt><dd>{shortId(t.project_id)}</dd></div>
{modeText ? <div><dt>模式</dt><dd>{modeText}</dd></div> : null}
{t.kind === "side" ? <div><dt>类型</dt><dd>旁聊(无 worktree/分支/容器)</dd></div> : null}
{t.forked_from ? <div><dt>分支自</dt><dd>{shortId(t.forked_from)}</dd></div> : null}
{t.coordinator_parts ? <div><dt>协调员</dt><dd>{t.coordinator_parts.length} 个子任务({t.coordinator_parts.map((p) => p.name).join("、")})</dd></div> : null}
{t.parent_task_id ? <div><dt>协调子任务</dt><dd>父任务 {shortId(t.parent_task_id)}{t.coordinator_part ? "(" + t.coordinator_part + ")" : ""}</dd></div> : null}
{t.parent_task_id && t.kind === "side" ? <div><dt>父会话</dt><dd>父任务 {shortId(t.parent_task_id)}{" "}<button type="button" onClick={() => ctx.showDetail(t.parent_task_id as string)}>跳转到父任务</button></dd></div> : null}
{t.parent_task_id && t.kind !== "side" ? <div><dt>协调子任务</dt><dd>父任务 {shortId(t.parent_task_id)}{t.coordinator_part ? "(" + t.coordinator_part + ")" : ""}</dd></div> : null}
{(t.attachments && t.attachments.length) ? <div><dt>附件</dt><dd>{t.attachments.join("、")}</dd></div> : null}
{t.pr_url ? <div><dt>PR</dt><dd><a href={t.pr_url} target="_blank" rel="noopener noreferrer">PR #{t.pr_number}</a></dd></div> : null}
</dl>
@@ -151,6 +153,7 @@ export default function TaskDetailPanel() {
</Card>
</>) : tab === "collab" ? (<>
<Card title="协调子任务"><CoordPanel /></Card>
<Card title="旁聊"><SideThreadPanel /></Card>
<Card title="子代理链路"><AgentsPanel /></Card>
<Card title="给其他任务发消息"><PeerPanel /></Card>
</>) : tab === "review" ? (<>
@@ -27,7 +27,17 @@ export default function TaskListSection({ onNew }: { onNew: () => void }) {
</div>
<div className="list-section-head">
<span>主要聊天</span>
<button type="button" aria-label="派发新任务" title="派发新任务" onClick={onNew}>+</button>
<span>
<button
type="button"
aria-label="开旁聊"
title={ctx.currentTask && ctx.currentTask.kind !== "side" && !ctx.currentTask.coordinator_parts ? "在选中主聊下开一个旁聊(不建 worktree)" : "先选中一个主聊任务,才能在它下面开旁聊"}
disabled={!ctx.currentTask || ctx.currentTask.kind === "side" || !!ctx.currentTask.coordinator_parts}
onClick={() => { if (ctx.currentTask) { ctx.setSideParentId(ctx.currentTask.id); onNew(); } }}
>开旁聊</button>
{" "}
<button type="button" aria-label="派发新任务" title="派发新任务" onClick={onNew}>+</button>
</span>
</div>
<div className="bar">
<select aria-label="状态筛选" value={ctx.statusFilter} onChange={(e) => ctx.setStatusFilter(e.target.value)}>
@@ -37,6 +47,9 @@ export default function TaskListSection({ onNew }: { onNew: () => void }) {
<select aria-label="执行器筛选" value={ctx.executorFilter} onChange={(e) => ctx.setExecutorFilter(e.target.value)}>
<option value="">执行器:全部</option><option value="local">local</option><option value="docker">docker</option><option value="cloud">cloud</option>
</select>
<select aria-label="类型筛选" value={ctx.kindFilter} onChange={(e) => ctx.setKindFilter(e.target.value)}>
<option value="">类型:全部</option><option value="main">主聊</option><option value="side">旁聊</option>
</select>
</div>
<div className="bar">
<button onClick={ctx.refreshTasks}>刷新</button>
@@ -62,7 +75,7 @@ export default function TaskListSection({ onNew }: { onNew: () => void }) {
<span className="issue-first" title={"任务首字:" + fc} aria-hidden="true">{fc}</span>
<span style={{ flex: 1, minWidth: 0 }}>
<div className="issue-title">
{t.plan ? "[计划] " : ""}{t.forked_from ? "[分支] " : ""}{t.coordinator_parts ? "[协调] " : ""}{t.parent_task_id ? "[协调子任务] " : ""}{String(t.prompt || shortId(t.id)).slice(0, 60)}
{t.plan ? "[计划] " : ""}{t.forked_from ? "[分支] " : ""}{t.coordinator_parts ? "[协调] " : ""}{t.parent_task_id ? (t.kind === "side" ? "[旁聊] " : "[协调子任务] ") : ""}{String(t.prompt || shortId(t.id)).slice(0, 60)}
</div>
<div className="issue-meta">
{shortId(t.id)} · <span className={"st-" + t.status}>{t.status}</span> · {t.executor || ""}{t.updated_at ? " · " + new Date(t.updated_at * 1000).toLocaleString() : ""}
@@ -12,18 +12,23 @@ export default function DetailActions() {
if (!t) return null;
const terminal = TRACE_TERMINAL.includes(t.status);
const retryOk = ["failed", "interrupted", "merge-failed"].includes(t.status);
// 旁聊(Muse 角色对齐 P2):无 worktree/分支,后端 /fork /merge
// /pr 一律 409,这里只留重试/取消/查成本,不渲染那三处入口。
const isSide = t.kind === "side";
return (
<>
<Bar>
<button disabled={!retryOk} title={retryOk ? "" : "仅 failed/interrupted/merge-failed 可重试"} onClick={() => ctx.doAction("retry")}>重试</button>
<button className="danger" disabled={terminal} title={terminal ? "终态任务不可取消" : ""} onClick={() => ctx.doAction("cancel")}>取消</button>
<button disabled={t.status !== "completed"} title={t.status !== "completed" ? "仅 completed 可合并" : ""} onClick={() => ctx.doAction("merge")}>合并(completed → merged)</button>
<button disabled={t.status !== "merged"} title={t.status !== "merged" ? "仅 merged 可提 PR" : ""} onClick={() => ctx.doAction("pr")}>提 PR(须 merged)</button>
{terminal ? <button onClick={() => ctx.setForkVisible(true)}>分支</button> : null}
{isSide ? null : (<>
<button disabled={t.status !== "completed"} title={t.status !== "completed" ? "仅 completed 可合并" : ""} onClick={() => ctx.doAction("merge")}>合并(completed → merged)</button>
<button disabled={t.status !== "merged"} title={t.status !== "merged" ? "仅 merged 可提 PR" : ""} onClick={() => ctx.doAction("pr")}>提 PR(须 merged)</button>
</>)}
{terminal && !isSide ? <button onClick={() => ctx.setForkVisible(true)}>分支</button> : null}
<button onClick={ctx.taskCost}>查成本</button>
<span className="muted">{ctx.actionMsg} {ctx.actionReasons}</span>
</Bar>
{ctx.forkVisible ? (
{ctx.forkVisible && !isSide ? (
<div>
<input type="text" placeholder="分支首轮消息(必填,带历史的新会话)" style={{ width: "100%", boxSizing: "border-box", margin: "6px 0" }} value={forkMsg} onChange={(e) => setForkMsg(e.target.value)} />
<Bar><button className="seal" onClick={async () => { await ctx.forkTask(forkMsg); setForkMsg(""); }}>创建分支任务</button><span className="muted">{ctx.forkMsg}</span></Bar>
@@ -19,6 +19,23 @@ export function CoordPanel() {
);
}
// 旁聊(Muse 角色对齐 P2):当前任务挂着的旁聊线程,与协调员
// 子任务列表同款组件复用;点行切到该旁聊的详情。
export function SideThreadPanel() {
const ctx = useConsole();
if (!ctx.currentTask) return <div className="muted">点任务行查看详情后,这里显示旁聊</div>;
if (ctx.sideThreads.length === 0) return <div className="muted">(暂无旁聊;列表“开旁聊”可在此任务下新建,不建 worktree)</div>;
return (
<DataListRaw>
{ctx.sideThreads.map((k) => (
<li key={k.id} onClick={() => ctx.showDetail(k.id)} style={{ cursor: "pointer" }}>
<span className="row-main">旁聊 | 状态 <span className={"st-" + k.status}>{k.status}</span> | {shortId(k.id)} | {String(k.prompt || "").slice(0, 60)}</span>
</li>
))}
</DataListRaw>
);
}
export function AgentsPanel() {
const ctx = useConsole();
if (!ctx.currentTask) return <div className="muted">点任务行查看详情后,这里显示子代理链路</div>;
+30 -30
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Codex 命令台</title>
<script type="module" crossorigin>(function(){const r=document.createElement("link").relList;if(r&&r.supports&&r.supports("modulepreload"))return;for(const y of document.querySelectorAll('link[rel="modulepreload"]'))f(y);new MutationObserver(y=>{for(const C of y)if(C.type==="childList")for(const M of C.addedNodes)M.tagName==="LINK"&&M.rel==="modulepreload"&&f(M)}).observe(document,{childList:!0,subtree:!0});function h(y){const C={};return y.integrity&&(C.integrity=y.integrity),y.referrerPolicy&&(C.referrerPolicy=y.referrerPolicy),y.crossOrigin==="use-credentials"?C.credentials="include":y.crossOrigin==="anonymous"?C.credentials="omit":C.credentials="same-origin",C}function f(y){if(y.ep)return;y.ep=!0;const C=h(y);fetch(y.href,C)}})();function Jh(i){return i&&i.__esModule&&Object.prototype.hasOwnProperty.call(i,"default")?i.default:i}var io={exports:{}},si={};/**
<script type="module" crossorigin>(function(){const r=document.createElement("link").relList;if(r&&r.supports&&r.supports("modulepreload"))return;for(const y of document.querySelectorAll('link[rel="modulepreload"]'))f(y);new MutationObserver(y=>{for(const A of y)if(A.type==="childList")for(const M of A.addedNodes)M.tagName==="LINK"&&M.rel==="modulepreload"&&f(M)}).observe(document,{childList:!0,subtree:!0});function h(y){const A={};return y.integrity&&(A.integrity=y.integrity),y.referrerPolicy&&(A.referrerPolicy=y.referrerPolicy),y.crossOrigin==="use-credentials"?A.credentials="include":y.crossOrigin==="anonymous"?A.credentials="omit":A.credentials="same-origin",A}function f(y){if(y.ep)return;y.ep=!0;const A=h(y);fetch(y.href,A)}})();function Jh(i){return i&&i.__esModule&&Object.prototype.hasOwnProperty.call(i,"default")?i.default:i}var co={exports:{}},ci={};/**
* @license React
* react-jsx-runtime.production.js
*
@@ -12,7 +12,7 @@
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/var Dh;function Hy(){if(Dh)return si;Dh=1;var i=Symbol.for("react.transitional.element"),r=Symbol.for("react.fragment");function h(f,y,C){var M=null;if(C!==void 0&&(M=""+C),y.key!==void 0&&(M=""+y.key),"key"in y){C={};for(var S in y)S!=="key"&&(C[S]=y[S])}else C=y;return y=C.ref,{$$typeof:i,type:f,key:M,ref:y!==void 0?y:null,props:C}}return si.Fragment=r,si.jsx=h,si.jsxs=h,si}var Uh;function By(){return Uh||(Uh=1,io.exports=Hy()),io.exports}var c=By(),co={exports:{}},yt={};/**
*/var Dh;function Hy(){if(Dh)return ci;Dh=1;var i=Symbol.for("react.transitional.element"),r=Symbol.for("react.fragment");function h(f,y,A){var M=null;if(A!==void 0&&(M=""+A),y.key!==void 0&&(M=""+y.key),"key"in y){A={};for(var p in y)p!=="key"&&(A[p]=y[p])}else A=y;return y=A.ref,{$$typeof:i,type:f,key:M,ref:y!==void 0?y:null,props:A}}return ci.Fragment=r,ci.jsx=h,ci.jsxs=h,ci}var kh;function By(){return kh||(kh=1,co.exports=Hy()),co.exports}var c=By(),so={exports:{}},gt={};/**
* @license React
* react.production.js
*
@@ -20,7 +20,7 @@
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/var wh;function qy(){if(wh)return yt;wh=1;var i=Symbol.for("react.transitional.element"),r=Symbol.for("react.portal"),h=Symbol.for("react.fragment"),f=Symbol.for("react.strict_mode"),y=Symbol.for("react.profiler"),C=Symbol.for("react.consumer"),M=Symbol.for("react.context"),S=Symbol.for("react.forward_ref"),_=Symbol.for("react.suspense"),O=Symbol.for("react.memo"),Y=Symbol.for("react.lazy"),x=Symbol.for("react.activity"),H=Symbol.for("react.view_transition"),L=Symbol.iterator;function tt(g){return g===null||typeof g!="object"?null:(g=L&&g[L]||g["@@iterator"],typeof g=="function"?g:null)}var dt={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},J=Object.assign,gt={};function _t(g,U,ct){this.props=g,this.context=U,this.refs=gt,this.updater=ct||dt}_t.prototype.isReactComponent={},_t.prototype.setState=function(g,U){if(typeof g!="object"&&typeof g!="function"&&g!=null)throw Error("takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,g,U,"setState")},_t.prototype.forceUpdate=function(g){this.updater.enqueueForceUpdate(this,g,"forceUpdate")};function Dt(){}Dt.prototype=_t.prototype;function Q(g,U,ct){this.props=g,this.context=U,this.refs=gt,this.updater=ct||dt}var ft=Q.prototype=new Dt;ft.constructor=Q,J(ft,_t.prototype),ft.isPureReactComponent=!0;var D=Array.isArray;function X(){}var et={H:null,A:null,T:null,S:null},xt=Object.prototype.hasOwnProperty;function Mt(g,U,ct){var it=ct.ref;return{$$typeof:i,type:g,key:U,ref:it!==void 0?it:null,props:ct}}function mt(g,U){return Mt(g.type,U,g.props)}function Nt(g){return typeof g=="object"&&g!==null&&g.$$typeof===i}function B(g){var U={"=":"=0",":":"=2"};return"$"+g.replace(/[=:]/g,function(ct){return U[ct]})}var nt=/\/+/g;function rt(g,U){return typeof g=="object"&&g!==null&&g.key!=null?B(""+g.key):U.toString(36)}function G(g){switch(g.status){case"fulfilled":return g.value;case"rejected":throw g.reason;default:switch(typeof g.status=="string"?g.then(X,X):(g.status="pending",g.then(function(U){g.status==="pending"&&(g.status="fulfilled",g.value=U)},function(U){g.status==="pending"&&(g.status="rejected",g.reason=U)})),g.status){case"fulfilled":return g.value;case"rejected":throw g.reason}}throw g}function A(g,U,ct,it,Ot){var Et=typeof g;(Et==="undefined"||Et==="boolean")&&(g=null);var kt=!1;if(g===null)kt=!0;else switch(Et){case"bigint":case"string":case"number":kt=!0;break;case"object":switch(g.$$typeof){case i:case r:kt=!0;break;case Y:return kt=g._init,A(kt(g._payload),U,ct,it,Ot)}}if(kt)return Ot=Ot(g),kt=it===""?"."+rt(g,0):it,D(Ot)?(ct="",kt!=null&&(ct=kt.replace(nt,"$&/")+"/"),A(Ot,U,ct,"",function(se){return se})):Ot!=null&&(Nt(Ot)&&(Ot=mt(Ot,ct+(Ot.key==null||g&&g.key===Ot.key?"":(""+Ot.key).replace(nt,"$&/")+"/")+kt)),U.push(Ot)),1;kt=0;var P=it===""?".":it+":";if(D(g))for(var ht=0;ht<g.length;ht++)it=g[ht],Et=P+rt(it,ht),kt+=A(it,U,ct,Et,Ot);else if(ht=tt(g),typeof ht=="function")for(g=ht.call(g),ht=0;!(it=g.next()).done;)it=it.value,Et=P+rt(it,ht++),kt+=A(it,U,ct,Et,Ot);else if(Et==="object"){if(typeof g.then=="function")return A(G(g),U,ct,it,Ot);throw U=String(g),Error("Objects are not valid as a React child (found: "+(U==="[object Object]"?"object with keys {"+Object.keys(g).join(", ")+"}":U)+"). If you meant to render a collection of children, use an array instead.")}return kt}function ut(g,U,ct){if(g==null)return g;var it=[],Ot=0;return A(g,it,"","",function(Et){return U.call(ct,Et,Ot++)}),it}function bt(g){if(g._status===-1){var U=g._result,ct=U();ct.then(function(it){(g._status===0||g._status===-1)&&(g._status=1,g._result=it,ct.status===void 0&&(ct.status="fulfilled",ct.value=it))},function(it){(g._status===0||g._status===-1)&&(g._status=2,g._result=it,ct.status===void 0&&(ct.status="rejected",ct.reason=it))}),g._status===-1&&(g._status=0,g._result=ct)}if(g._status===1)return g._result.default;throw g._result}var pt=typeof reportError=="function"?reportError:function(g){if(typeof window=="object"&&typeof window.ErrorEvent=="function"){var U=new window.ErrorEvent("error",{bubbles:!0,cancelable:!0,message:typeof g=="object"&&g!==null&&typeof g.message=="string"?String(g.message):String(g),error:g});if(!window.dispatchEvent(U))return}else if(typeof process=="object"&&typeof process.emit=="function"){process.emit("uncaughtException",g);return}console.error(g)};function Se(g){var U=et.T,ct={};ct.types=U!==null?U.types:null,et.T=ct;try{var it=g(),Ot=et.S;Ot!==null&&Ot(ct,it),typeof it=="object"&&it!==null&&typeof it.then=="function"&&it.then(X,pt)}catch(Et){pt(Et)}finally{U!==null&&ct.types!==null&&(U.types=ct.types),et.T=U}}function we(g){var U=et.T;if(U!==null){var ct=U.types;ct===null?U.types=[g]:ct.indexOf(g)===-1&&ct.push(g)}else Se(we.bind(null,g))}var Ne={map:ut,forEach:function(g,U,ct){ut(g,function(){U.apply(this,arguments)},ct)},count:function(g){var U=0;return ut(g,function(){U++}),U}Line truncated
*/var Uh;function qy(){if(Uh)return gt;Uh=1;var i=Symbol.for("react.transitional.element"),r=Symbol.for("react.portal"),h=Symbol.for("react.fragment"),f=Symbol.for("react.strict_mode"),y=Symbol.for("react.profiler"),A=Symbol.for("react.consumer"),M=Symbol.for("react.context"),p=Symbol.for("react.forward_ref"),_=Symbol.for("react.suspense"),R=Symbol.for("react.memo"),Q=Symbol.for("react.lazy"),b=Symbol.for("react.activity"),H=Symbol.for("react.view_transition"),Z=Symbol.iterator;function W(g){return g===null||typeof g!="object"?null:(g=Z&&g[Z]||g["@@iterator"],typeof g=="function"?g:null)}var ot={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},rt=Object.assign,bt={};function it(g,w,et){this.props=g,this.context=w,this.refs=bt,this.updater=et||ot}it.prototype.isReactComponent={},it.prototype.setState=function(g,w){if(typeof g!="object"&&typeof g!="function"&&g!=null)throw Error("takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,g,w,"setState")},it.prototype.forceUpdate=function(g){this.updater.enqueueForceUpdate(this,g,"forceUpdate")};function wt(){}wt.prototype=it.prototype;function L(g,w,et){this.props=g,this.context=w,this.refs=bt,this.updater=et||ot}var ut=L.prototype=new wt;ut.constructor=L,rt(ut,it.prototype),ut.isPureReactComponent=!0;var O=Array.isArray;function V(){}var lt={H:null,A:null,T:null,S:null},St=Object.prototype.hasOwnProperty;function Nt(g,w,et){var nt=et.ref;return{$$typeof:i,type:g,key:w,ref:nt!==void 0?nt:null,props:et}}function mt(g,w){return Nt(g.type,w,g.props)}function Tt(g){return typeof g=="object"&&g!==null&&g.$$typeof===i}function q(g){var w={"=":"=0",":":"=2"};return"$"+g.replace(/[=:]/g,function(et){return w[et]})}var ht=/\/+/g;function _t(g,w){return typeof g=="object"&&g!==null&&g.key!=null?q(""+g.key):w.toString(36)}function G(g){switch(g.status){case"fulfilled":return g.value;case"rejected":throw g.reason;default:switch(typeof g.status=="string"?g.then(V,V):(g.status="pending",g.then(function(w){g.status==="pending"&&(g.status="fulfilled",g.value=w)},function(w){g.status==="pending"&&(g.status="rejected",g.reason=w)})),g.status){case"fulfilled":return g.value;case"rejected":throw g.reason}}throw g}function Y(g,w,et,nt,Mt){var Ct=typeof g;(Ct==="undefined"||Ct==="boolean")&&(g=null);var kt=!1;if(g===null)kt=!0;else switch(Ct){case"bigint":case"string":case"number":kt=!0;break;case"object":switch(g.$$typeof){case i:case r:kt=!0;break;case Q:return kt=g._init,Y(kt(g._payload),w,et,nt,Mt)}}if(kt)return Mt=Mt(g),kt=nt===""?"."+_t(g,0):nt,O(Mt)?(et="",kt!=null&&(et=kt.replace(ht,"$&/")+"/"),Y(Mt,w,et,"",function(Ne){return Ne})):Mt!=null&&(Tt(Mt)&&(Mt=mt(Mt,et+(Mt.key==null||g&&g.key===Mt.key?"":(""+Mt.key).replace(ht,"$&/")+"/")+kt)),w.push(Mt)),1;kt=0;var P=nt===""?".":nt+":";if(O(g))for(var ft=0;ft<g.length;ft++)nt=g[ft],Ct=P+_t(nt,ft),kt+=Y(nt,w,et,Ct,Mt);else if(ft=W(g),typeof ft=="function")for(g=ft.call(g),ft=0;!(nt=g.next()).done;)nt=nt.value,Ct=P+_t(nt,ft++),kt+=Y(nt,w,et,Ct,Mt);else if(Ct==="object"){if(typeof g.then=="function")return Y(G(g),w,et,nt,Mt);throw w=String(g),Error("Objects are not valid as a React child (found: "+(w==="[object Object]"?"object with keys {"+Object.keys(g).join(", ")+"}":w)+"). If you meant to render a collection of children, use an array instead.")}return kt}function z(g,w,et){if(g==null)return g;var nt=[],Mt=0;return Y(g,nt,"","",function(Ct){return w.call(et,Ct,Mt++)}),nt}function vt(g){if(g._status===-1){var w=g._result,et=w();et.then(function(nt){(g._status===0||g._status===-1)&&(g._status=1,g._result=nt,et.status===void 0&&(et.status="fulfilled",et.value=nt))},function(nt){(g._status===0||g._status===-1)&&(g._status=2,g._result=nt,et.status===void 0&&(et.status="rejected",et.reason=nt))}),g._status===-1&&(g._status=0,g._result=et)}if(g._status===1)return g._result.default;throw g._result}var pt=typeof reportError=="function"?reportError:function(g){if(typeof window=="object"&&typeof window.ErrorEvent=="function"){var w=new window.ErrorEvent("error",{bubbles:!0,cancelable:!0,message:typeof g=="object"&&g!==null&&typeof g.message=="string"?String(g.message):String(g),error:g});if(!window.dispatchEvent(w))return}else if(typeof process=="object"&&typeof process.emit=="function"){process.emit("uncaughtException",g);return}console.error(g)};function ee(g){var w=lt.T,et={};et.types=w!==null?w.types:null,lt.T=et;try{var nt=g(),Mt=lt.S;Mt!==null&&Mt(et,nt),typeof nt=="object"&&nt!==null&&typeof nt.then=="function"&&nt.then(V,pt)}catch(Ct){pt(Ct)}finally{w!==null&&et.types!==null&&(w.types=et.types),lt.T=w}}function fe(g){var w=lt.T;if(w!==null){var et=w.types;et===null?w.types=[g]:et.indexOf(g)===-1&&et.push(g)}else ee(fe.bind(null,g))}var ne={map:z,forEach:function(g,w,et){z(g,function(){w.apply(this,arguments)},et)},count:function(g){var w=0;return z(g,function(){w++}),w},toALine truncated
* @license React
* scheduler.production.js
*
@@ -28,7 +28,7 @@
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/var Hh;function Gy(){return Hh||(Hh=1,(function(i){function r(G,A){var ut=G.length;G.push(A);t:for(;0<ut;){var bt=ut-1>>>1,pt=G[bt];if(0<y(pt,A))G[bt]=A,G[ut]=pt,ut=bt;else break t}}function h(G){return G.length===0?null:G[0]}function f(G){if(G.length===0)return null;var A=G[0],ut=G.pop();if(ut!==A){G[0]=ut;t:for(var bt=0,pt=G.length,Se=pt>>>1;bt<Se;){var we=2*(bt+1)-1,Ne=G[we],g=we+1,U=G[g];if(0>y(Ne,ut))g<pt&&0>y(U,Ne)?(G[bt]=U,G[g]=ut,bt=g):(G[bt]=Ne,G[we]=ut,bt=we);else if(g<pt&&0>y(U,ut))G[bt]=U,G[g]=ut,bt=g;else break t}}return A}function y(G,A){var ut=G.sortIndex-A.sortIndex;return ut!==0?ut:G.id-A.id}if(i.unstable_now=void 0,typeof performance=="object"&&typeof performance.now=="function"){var C=performance;i.unstable_now=function(){return C.now()}}else{var M=Date,S=M.now();i.unstable_now=function(){return M.now()-S}}var _=[],O=[],Y=1,x=null,H=3,L=!1,tt=!1,dt=!1,J=!1,gt=typeof setTimeout=="function"?setTimeout:null,_t=typeof clearTimeout=="function"?clearTimeout:null,Dt=typeof setImmediate<"u"?setImmediate:null;function Q(G){for(var A=h(O);A!==null;){if(A.callback===null)f(O);else if(A.startTime<=G)f(O),A.sortIndex=A.expirationTime,r(_,A);else break;A=h(O)}}function ft(G){if(dt=!1,Q(G),!tt)if(h(_)!==null)tt=!0,D||(D=!0,Nt());else{var A=h(O);A!==null&&rt(ft,A.startTime-G)}}var D=!1,X=-1,et=5,xt=-1;function Mt(){return J?!0:!(i.unstable_now()-xt<et)}function mt(){if(J=!1,D){var G=i.unstable_now();xt=G;var A=!0;try{t:{tt=!1,dt&&(dt=!1,_t(X),X=-1),L=!0;var ut=H;try{e:{for(Q(G),x=h(_);x!==null&&!(x.expirationTime>G&&Mt());){var bt=x.callback;if(typeof bt=="function"){x.callback=null,H=x.priorityLevel;var pt=bt(x.expirationTime<=G);if(G=i.unstable_now(),typeof pt=="function"){x.callback=pt,Q(G),A=!0;break e}x===h(_)&&f(_),Q(G)}else f(_);x=h(_)}if(x!==null)A=!0;else{var Se=h(O);Se!==null&&rt(ft,Se.startTime-G),A=!1}}break t}finally{x=null,H=ut,L=!1}A=void 0}}finally{A?Nt():D=!1}}}var Nt;if(typeof Dt=="function")Nt=function(){Dt(mt)};else if(typeof MessageChannel<"u"){var B=new MessageChannel,nt=B.port2;B.port1.onmessage=mt,Nt=function(){nt.postMessage(null)}}else Nt=function(){gt(mt,0)};function rt(G,A){X=gt(function(){G(i.unstable_now())},A)}i.unstable_IdlePriority=5,i.unstable_ImmediatePriority=1,i.unstable_LowPriority=4,i.unstable_NormalPriority=3,i.unstable_Profiling=null,i.unstable_UserBlockingPriority=2,i.unstable_cancelCallback=function(G){G.callback=null},i.unstable_forceFrameRate=function(G){0>G||125<G?console.error("forceFrameRate takes a positive int between 0 and 125, forcing frame rates higher than 125 fps is not supported"):et=0<G?Math.floor(1e3/G):5},i.unstable_getCurrentPriorityLevel=function(){return H},i.unstable_next=function(G){switch(H){case 1:case 2:case 3:var A=3;break;default:A=H}var ut=H;H=A;try{return G()}finally{H=ut}},i.unstable_requestPaint=function(){J=!0},i.unstable_runWithPriority=function(G,A){switch(G){case 1:case 2:case 3:case 4:case 5:break;default:G=3}var ut=H;H=G;try{return A()}finally{H=ut}},i.unstable_scheduleCallback=function(G,A,ut){var bt=i.unstable_now();switch(typeof ut=="object"&&ut!==null?(ut=ut.delay,ut=typeof ut=="number"&&0<ut?bt+ut:bt):ut=bt,G){case 1:var pt=-1;break;case 2:pt=250;break;case 5:pt=1073741823;break;case 4:pt=1e4;break;default:pt=5e3}return pt=ut+pt,G={id:Y++,callback:A,priorityLevel:G,startTime:ut,expirationTime:pt,sortIndex:-1},ut>bt?(G.sortIndex=ut,r(O,G),h(_)===null&&G===h(O)&&(dt?(_t(X),X=-1):dt=!0,rt(ft,ut-bt))):(G.sortIndex=pt,r(_,G),tt||L||(tt=!0,D||(D=!0,Nt()))),G},i.unstable_shouldYield=Mt,i.unstable_wrapCallback=function(G){var A=H;return function(){var ut=H;H=A;try{return G.apply(this,arguments)}finally{H=ut}}}})(oo)),oo}var Bh;function Qy(){return Bh||(Bh=1,ro.exports=Gy()),ro.exports}var fo={exports:{}},Ue={};/**
*/var Hh;function Gy(){return Hh||(Hh=1,(function(i){function r(G,Y){var z=G.length;G.push(Y);t:for(;0<z;){var vt=z-1>>>1,pt=G[vt];if(0<y(pt,Y))G[vt]=Y,G[z]=pt,z=vt;else break t}}function h(G){return G.length===0?null:G[0]}function f(G){if(G.length===0)return null;var Y=G[0],z=G.pop();if(z!==Y){G[0]=z;t:for(var vt=0,pt=G.length,ee=pt>>>1;vt<ee;){var fe=2*(vt+1)-1,ne=G[fe],g=fe+1,w=G[g];if(0>y(ne,z))g<pt&&0>y(w,ne)?(G[vt]=w,G[g]=z,vt=g):(G[vt]=ne,G[fe]=z,vt=fe);else if(g<pt&&0>y(w,z))G[vt]=w,G[g]=z,vt=g;else break t}}return Y}function y(G,Y){var z=G.sortIndex-Y.sortIndex;return z!==0?z:G.id-Y.id}if(i.unstable_now=void 0,typeof performance=="object"&&typeof performance.now=="function"){var A=performance;i.unstable_now=function(){return A.now()}}else{var M=Date,p=M.now();i.unstable_now=function(){return M.now()-p}}var _=[],R=[],Q=1,b=null,H=3,Z=!1,W=!1,ot=!1,rt=!1,bt=typeof setTimeout=="function"?setTimeout:null,it=typeof clearTimeout=="function"?clearTimeout:null,wt=typeof setImmediate<"u"?setImmediate:null;function L(G){for(var Y=h(R);Y!==null;){if(Y.callback===null)f(R);else if(Y.startTime<=G)f(R),Y.sortIndex=Y.expirationTime,r(_,Y);else break;Y=h(R)}}function ut(G){if(ot=!1,L(G),!W)if(h(_)!==null)W=!0,O||(O=!0,Tt());else{var Y=h(R);Y!==null&&_t(ut,Y.startTime-G)}}var O=!1,V=-1,lt=5,St=-1;function Nt(){return rt?!0:!(i.unstable_now()-St<lt)}function mt(){if(rt=!1,O){var G=i.unstable_now();St=G;var Y=!0;try{t:{W=!1,ot&&(ot=!1,it(V),V=-1),Z=!0;var z=H;try{e:{for(L(G),b=h(_);b!==null&&!(b.expirationTime>G&&Nt());){var vt=b.callback;if(typeof vt=="function"){b.callback=null,H=b.priorityLevel;var pt=vt(b.expirationTime<=G);if(G=i.unstable_now(),typeof pt=="function"){b.callback=pt,L(G),Y=!0;break e}b===h(_)&&f(_),L(G)}else f(_);b=h(_)}if(b!==null)Y=!0;else{var ee=h(R);ee!==null&&_t(ut,ee.startTime-G),Y=!1}}break t}finally{b=null,H=z,Z=!1}Y=void 0}}finally{Y?Tt():O=!1}}}var Tt;if(typeof wt=="function")Tt=function(){wt(mt)};else if(typeof MessageChannel<"u"){var q=new MessageChannel,ht=q.port2;q.port1.onmessage=mt,Tt=function(){ht.postMessage(null)}}else Tt=function(){bt(mt,0)};function _t(G,Y){V=bt(function(){G(i.unstable_now())},Y)}i.unstable_IdlePriority=5,i.unstable_ImmediatePriority=1,i.unstable_LowPriority=4,i.unstable_NormalPriority=3,i.unstable_Profiling=null,i.unstable_UserBlockingPriority=2,i.unstable_cancelCallback=function(G){G.callback=null},i.unstable_forceFrameRate=function(G){0>G||125<G?console.error("forceFrameRate takes a positive int between 0 and 125, forcing frame rates higher than 125 fps is not supported"):lt=0<G?Math.floor(1e3/G):5},i.unstable_getCurrentPriorityLevel=function(){return H},i.unstable_next=function(G){switch(H){case 1:case 2:case 3:var Y=3;break;default:Y=H}var z=H;H=Y;try{return G()}finally{H=z}},i.unstable_requestPaint=function(){rt=!0},i.unstable_runWithPriority=function(G,Y){switch(G){case 1:case 2:case 3:case 4:case 5:break;default:G=3}var z=H;H=G;try{return Y()}finally{H=z}},i.unstable_scheduleCallback=function(G,Y,z){var vt=i.unstable_now();switch(typeof z=="object"&&z!==null?(z=z.delay,z=typeof z=="number"&&0<z?vt+z:vt):z=vt,G){case 1:var pt=-1;break;case 2:pt=250;break;case 5:pt=1073741823;break;case 4:pt=1e4;break;default:pt=5e3}return pt=z+pt,G={id:Q++,callback:Y,priorityLevel:G,startTime:z,expirationTime:pt,sortIndex:-1},z>vt?(G.sortIndex=z,r(R,G),h(_)===null&&G===h(R)&&(ot?(it(V),V=-1):ot=!0,_t(ut,z-vt))):(G.sortIndex=pt,r(_,G),W||Z||(W=!0,O||(O=!0,Tt()))),G},i.unstable_shouldYield=Nt,i.unstable_wrapCallback=function(G){var Y=H;return function(){var z=H;H=Y;try{return G.apply(this,arguments)}finally{H=z}}}})(fo)),fo}var Bh;function Qy(){return Bh||(Bh=1,oo.exports=Gy()),oo.exports}var mo={exports:{}},ke={};/**
* @license React
* react-dom.production.js
*
@@ -36,7 +36,7 @@
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/var qh;function Xy(){if(qh)return Ue;qh=1;var i=go();function r(Y){var x="https://react.dev/errors/"+Y;if(1<arguments.length){x+="?args[]="+encodeURIComponent(arguments[1]);for(var H=2;H<arguments.length;H++)x+="&args[]="+encodeURIComponent(arguments[H])}return"Minified React error #"+Y+"; visit "+x+" for the full message or use the non-minified dev environment for full errors and additional helpful warnings."}function h(){}var f={d:{f:h,r:function(){throw Error(r(522))},D:h,C:h,L:h,m:h,X:h,S:h,M:h},p:0,findDOMNode:null},y=Symbol.for("react.portal"),C=Symbol.for("react.recoverable"),M=Symbol.for("react.optimistic_key");function S(Y,x,H){var L=3<arguments.length&&arguments[3]!==void 0?arguments[3]:null;return{$$typeof:y,key:L==null?null:L===M?M:""+L,children:Y,containerInfo:x,implementation:H}}var _=i.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE;function O(Y,x){if(Y==="font")return"";if(typeof x=="string")return x==="use-credentials"?x:""}return Ue.__DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE=f,Ue.browser=function(Y){return{$$typeof:C,_reason:Y}},Ue.createPortal=function(Y,x){var H=2<arguments.length&&arguments[2]!==void 0?arguments[2]:null;if(!x||x.nodeType!==1&&x.nodeType!==9&&x.nodeType!==11)throw Error(r(299));return S(Y,x,null,H)},Ue.flushSync=function(Y){var x=_.T,H=f.p;try{if(_.T=null,f.p=2,Y)return Y()}finally{_.T=x,f.p=H,f.d.f()}},Ue.preconnect=function(Y,x){typeof Y=="string"&&(x?(x=x.crossOrigin,x=typeof x=="string"?x==="use-credentials"?x:"":void 0):x=null,f.d.C(Y,x))},Ue.prefetchDNS=function(Y){typeof Y=="string"&&f.d.D(Y)},Ue.preinit=function(Y,x){if(typeof Y=="string"&&x&&typeof x.as=="string"){var H=x.as,L=O(H,x.crossOrigin),tt=typeof x.integrity=="string"?x.integrity:void 0,dt=typeof x.fetchPriority=="string"?x.fetchPriority:void 0;H==="style"?f.d.S(Y,typeof x.precedence=="string"?x.precedence:void 0,{crossOrigin:L,integrity:tt,fetchPriority:dt}):H==="script"&&f.d.X(Y,{crossOrigin:L,integrity:tt,fetchPriority:dt,nonce:typeof x.nonce=="string"?x.nonce:void 0})}},Ue.preinitModule=function(Y,x){if(typeof Y=="string")if(typeof x=="object"&&x!==null){if(x.as==null||x.as==="script"){var H=O(x.as,x.crossOrigin);f.d.M(Y,{crossOrigin:H,integrity:typeof x.integrity=="string"?x.integrity:void 0,nonce:typeof x.nonce=="string"?x.nonce:void 0,fetchPriority:typeof x.fetchPriority=="string"?x.fetchPriority:void 0})}}else x==null&&f.d.M(Y)},Ue.preload=function(Y,x){if(typeof Y=="string"&&typeof x=="object"&&x!==null&&typeof x.as=="string"){var H=x.as,L=O(H,x.crossOrigin);f.d.L(Y,H,{crossOrigin:L,integrity:typeof x.integrity=="string"?x.integrity:void 0,nonce:typeof x.nonce=="string"?x.nonce:void 0,type:typeof x.type=="string"?x.type:void 0,fetchPriority:typeof x.fetchPriority=="string"?x.fetchPriority:void 0,referrerPolicy:typeof x.referrerPolicy=="string"?x.referrerPolicy:void 0,imageSrcSet:typeof x.imageSrcSet=="string"?x.imageSrcSet:void 0,imageSizes:typeof x.imageSizes=="string"?x.imageSizes:void 0,media:typeof x.media=="string"?x.media:void 0})}},Ue.preloadModule=function(Y,x){if(typeof Y=="string")if(x){var H=O(x.as,x.crossOrigin);f.d.m(Y,{as:typeof x.as=="string"&&x.as!=="script"?x.as:void 0,crossOrigin:H,integrity:typeof x.integrity=="string"?x.integrity:void 0,nonce:typeof x.nonce=="string"?x.nonce:void 0,fetchPriority:typeof x.fetchPriority=="string"?x.fetchPriority:void 0})}else f.d.m(Y)},Ue.requestFormReset=function(Y){f.d.r(Y)},Ue.unstable_batchedUpdates=function(Y,x){return Y(x)},Ue.useFormState=function(Y,x,H){return _.H.useFormState(Y,x,H)},Ue.useFormStatus=function(){return _.H.useHostTransitionStatus()},Ue.version="19.3.1",Ue}var Yh;function Ly(){if(Yh)return fo.exports;Yh=1;function i(){if(!(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(i)}catch(r){console.error(r)}}return i(),fo.exports=Xy(),fo.exports}/**
*/var qh;function Xy(){if(qh)return ke;qh=1;var i=po();function r(Q){var b="https://react.dev/errors/"+Q;if(1<arguments.length){b+="?args[]="+encodeURIComponent(arguments[1]);for(var H=2;H<arguments.length;H++)b+="&args[]="+encodeURIComponent(arguments[H])}return"Minified React error #"+Q+"; visit "+b+" for the full message or use the non-minified dev environment for full errors and additional helpful warnings."}function h(){}var f={d:{f:h,r:function(){throw Error(r(522))},D:h,C:h,L:h,m:h,X:h,S:h,M:h},p:0,findDOMNode:null},y=Symbol.for("react.portal"),A=Symbol.for("react.recoverable"),M=Symbol.for("react.optimistic_key");function p(Q,b,H){var Z=3<arguments.length&&arguments[3]!==void 0?arguments[3]:null;return{$$typeof:y,key:Z==null?null:Z===M?M:""+Z,children:Q,containerInfo:b,implementation:H}}var _=i.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE;function R(Q,b){if(Q==="font")return"";if(typeof b=="string")return b==="use-credentials"?b:""}return ke.__DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE=f,ke.browser=function(Q){return{$$typeof:A,_reason:Q}},ke.createPortal=function(Q,b){var H=2<arguments.length&&arguments[2]!==void 0?arguments[2]:null;if(!b||b.nodeType!==1&&b.nodeType!==9&&b.nodeType!==11)throw Error(r(299));return p(Q,b,null,H)},ke.flushSync=function(Q){var b=_.T,H=f.p;try{if(_.T=null,f.p=2,Q)return Q()}finally{_.T=b,f.p=H,f.d.f()}},ke.preconnect=function(Q,b){typeof Q=="string"&&(b?(b=b.crossOrigin,b=typeof b=="string"?b==="use-credentials"?b:"":void 0):b=null,f.d.C(Q,b))},ke.prefetchDNS=function(Q){typeof Q=="string"&&f.d.D(Q)},ke.preinit=function(Q,b){if(typeof Q=="string"&&b&&typeof b.as=="string"){var H=b.as,Z=R(H,b.crossOrigin),W=typeof b.integrity=="string"?b.integrity:void 0,ot=typeof b.fetchPriority=="string"?b.fetchPriority:void 0;H==="style"?f.d.S(Q,typeof b.precedence=="string"?b.precedence:void 0,{crossOrigin:Z,integrity:W,fetchPriority:ot}):H==="script"&&f.d.X(Q,{crossOrigin:Z,integrity:W,fetchPriority:ot,nonce:typeof b.nonce=="string"?b.nonce:void 0})}},ke.preinitModule=function(Q,b){if(typeof Q=="string")if(typeof b=="object"&&b!==null){if(b.as==null||b.as==="script"){var H=R(b.as,b.crossOrigin);f.d.M(Q,{crossOrigin:H,integrity:typeof b.integrity=="string"?b.integrity:void 0,nonce:typeof b.nonce=="string"?b.nonce:void 0,fetchPriority:typeof b.fetchPriority=="string"?b.fetchPriority:void 0})}}else b==null&&f.d.M(Q)},ke.preload=function(Q,b){if(typeof Q=="string"&&typeof b=="object"&&b!==null&&typeof b.as=="string"){var H=b.as,Z=R(H,b.crossOrigin);f.d.L(Q,H,{crossOrigin:Z,integrity:typeof b.integrity=="string"?b.integrity:void 0,nonce:typeof b.nonce=="string"?b.nonce:void 0,type:typeof b.type=="string"?b.type:void 0,fetchPriority:typeof b.fetchPriority=="string"?b.fetchPriority:void 0,referrerPolicy:typeof b.referrerPolicy=="string"?b.referrerPolicy:void 0,imageSrcSet:typeof b.imageSrcSet=="string"?b.imageSrcSet:void 0,imageSizes:typeof b.imageSizes=="string"?b.imageSizes:void 0,media:typeof b.media=="string"?b.media:void 0})}},ke.preloadModule=function(Q,b){if(typeof Q=="string")if(b){var H=R(b.as,b.crossOrigin);f.d.m(Q,{as:typeof b.as=="string"&&b.as!=="script"?b.as:void 0,crossOrigin:H,integrity:typeof b.integrity=="string"?b.integrity:void 0,nonce:typeof b.nonce=="string"?b.nonce:void 0,fetchPriority:typeof b.fetchPriority=="string"?b.fetchPriority:void 0})}else f.d.m(Q)},ke.requestFormReset=function(Q){f.d.r(Q)},ke.unstable_batchedUpdates=function(Q,b){return Q(b)},ke.useFormState=function(Q,b,H){return _.H.useFormState(Q,b,H)},ke.useFormStatus=function(){return _.H.useHostTransitionStatus()},ke.version="19.3.1",ke}var Yh;function Ly(){if(Yh)return mo.exports;Yh=1;function i(){if(!(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(i)}catch(r){console.error(r)}}return i(),mo.exports=Xy(),mo.exports}/**
* @license React
* react-dom-client.production.js
*
@@ -44,37 +44,37 @@
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/var Gh;function Vy(){if(Gh)return ri;Gh=1;var i=Qy(),r=go(),h=Ly();function f(t){var e="https://react.dev/errors/"+t;if(1<arguments.length){e+="?args[]="+encodeURIComponent(arguments[1]);for(var l=2;l<arguments.length;l++)e+="&args[]="+encodeURIComponent(arguments[l])}return"Minified React error #"+t+"; visit "+e+" for the full message or use the non-minified dev environment for full errors and additional helpful warnings."}function y(t){return!(!t||t.nodeType!==1&&t.nodeType!==9&&t.nodeType!==11)}function C(t){for(var e=t,l=e;l&&!l.alternate;)e=l,(e.flags&4098)!==0&&(t=e.return),l=e.return;for(;e.return;)e=e.return;return e.tag===3?t:null}function M(t){if(t.tag===13){var e=t.memoizedState;if(e===null&&(t=t.alternate,t!==null&&(e=t.memoizedState)),e!==null)return e.dehydrated}return null}function S(t){if(t.tag===31){var e=t.memoizedState;if(e===null&&(t=t.alternate,t!==null&&(e=t.memoizedState)),e!==null)return e.dehydrated}return null}function _(t){if(C(t)!==t)throw Error(f(188))}function O(t){var e=t.alternate;if(!e){if(e=C(t),e===null)throw Error(f(188));return e!==t?null:t}for(var l=t,a=e;;){var n=l.return;if(n===null)break;var u=n.alternate;if(u===null){if(a=n.return,a!==null){l=a;continue}break}if(n.child===u.child){for(u=n.child;u;){if(u===l)return _(n),t;if(u===a)return _(n),e;u=u.sibling}throw Error(f(188))}if(l.return!==a.return)l=n,a=u;else{for(var s=!1,o=n.child;o;){if(o===l){s=!0,l=n,a=u;break}if(o===a){s=!0,a=n,l=u;break}o=o.sibling}if(!s){for(o=u.child;o;){if(o===l){s=!0,l=u,a=n;break}if(o===a){s=!0,a=u,l=n;break}o=o.sibling}if(!s)throw Error(f(189))}}if(l.alternate!==a)throw Error(f(190))}if(l.tag!==3)throw Error(f(188));return l.stateNode.current===l?t:e}function Y(t){var e=t.tag;if(e===5||e===26||e===27||e===6)return t;for(t=t.child;t!==null;){if(e=Y(t),e!==null)return e;t=t.sibling}return null}function x(t,e,l,a,n,u){for(;t!==null;){if((t.tag===5||t.tag===27||t.tag===6)&&l(t,a,n,u)||(t.tag!==22||t.memoizedState===null)&&(e||t.tag!==5&&t.tag!==27)&&x(t.child,e,l,a,n,u))return!0;t=t.sibling}return!1}function H(t){for(t=t.return;t!==null;){if(t.tag===3||t.tag===5||t.tag===27)return t;t=t.return}return null}function L(t){var e=!1;for(t=t.return;t!==null&&(t.tag===4&&(e=!0),!(t.tag===3||t.tag===5||t.tag===27));)t=t.return;return e}function tt(t){var e=[null,null],l=H(t);return l===null||dt(e,t,l.child,{foundSelf:!1}),e}function dt(t,e,l,a){for(;l!==null;){if(l===e)a.foundSelf=!0;else if(l.tag===5||l.tag===27||l.tag===6){if(a.foundSelf)return t[1]=l,!0;t[0]=l}else if((l.tag!==22||l.memoizedState===null)&&dt(t,e,l.child,a))return!0;l=l.sibling}return!1}function J(t){switch(t.tag){case 5:case 27:case 6:return t.stateNode;case 3:return t.stateNode.containerInfo;default:throw Error(f(559))}}var gt=null,_t=null;function Dt(t,e,l){return t===l?!0:t===e?(gt=t,!0):!1}function Q(t,e,l){return t===l?(_t=t,!1):t===e?(_t!==null&&(gt=t),!0):!1}function ft(t){if(t===null)return null;do t=t===null?null:t.return;while(t&&t.tag!==5&&t.tag!==27&&t.tag!==3);return t||null}function D(t,e,l){for(var a=0,n=t;n;n=l(n))a++;n=0;for(var u=e;u;u=l(u))n++;for(;0<a-n;)t=l(t),a--;for(;0<n-a;)e=l(e),n--;for(;a--;){if(t===e||e!==null&&t===e.alternate)return t;t=l(t),e=l(e)}return null}var X=Object.assign,et=Symbol.for("react.element"),xt=Symbol.for("react.transitional.element"),Mt=Symbol.for("react.portal"),mt=Symbol.for("react.fragment"),Nt=Symbol.for("react.strict_mode"),B=Symbol.for("react.profiler"),nt=Symbol.for("react.consumer"),rt=Symbol.for("react.context"),G=Symbol.for("react.forward_ref"),A=Symbol.for("react.suspense"),ut=Symbol.for("react.suspense_list"),bt=Symbol.for("react.memo"),pt=Symbol.for("react.lazy"),Se=Symbol.for("react.activity"),we=Symbol.for("react.legacy_hidden"),Ne=Symbol.for("react.memo_cache_sentinel"),g=Symbol.for("react.view_transition"),U=Symbol.for("react.recoverable"),ct=Symbol.iterator;function it(t){return t===null||typeof t!="object"?null:(t=ct&&t[ct]||t["@@iterator"],typeof t=="function"?t:null)}var Ot=Symbol.for("react.client.reference");function Et(t){if(t==null)return null;if(typeof t=="function")return t.$$typeof===Ot?null:t.displayName||t.name||null;if(typeof t=="string")return t;switch(t){case mt:return"Fragment";case B:return"Profiler";case Nt:return"StrictMode";case A:return"Suspense";case ut:return"SuspenseList";case Se:return"Activity";case g:return"ViewTransition"}if(typeof t=="object")switch(t.$$typeof){case Mt:return"Portal";case rt:return t.displayName||"Context";case nt:return(t._context.displayName||"Context")+".Consumer";case G:var e=t.render;return t=t.displayName,t||(t=e.displayName||e.name||"",t=t!==""?"ForwardRef("+t+")":"ForwardRef"),t;case bt:return e=t.displayName||null,e!==null?e:Et(t.type)||"Memo";case pt:e=t._payload,t=t._init;try{return Et(t(e))}catch{}}return null}var kt=Array.isArray,P=r.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE,ht=h.__DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE,se={pending:!1,data:null,method:null,actiLine truncated
*/var Gh;function Vy(){if(Gh)return si;Gh=1;var i=Qy(),r=po(),h=Ly();function f(t){var e="https://react.dev/errors/"+t;if(1<arguments.length){e+="?args[]="+encodeURIComponent(arguments[1]);for(var l=2;l<arguments.length;l++)e+="&args[]="+encodeURIComponent(arguments[l])}return"Minified React error #"+t+"; visit "+e+" for the full message or use the non-minified dev environment for full errors and additional helpful warnings."}function y(t){return!(!t||t.nodeType!==1&&t.nodeType!==9&&t.nodeType!==11)}function A(t){for(var e=t,l=e;l&&!l.alternate;)e=l,(e.flags&4098)!==0&&(t=e.return),l=e.return;for(;e.return;)e=e.return;return e.tag===3?t:null}function M(t){if(t.tag===13){var e=t.memoizedState;if(e===null&&(t=t.alternate,t!==null&&(e=t.memoizedState)),e!==null)return e.dehydrated}return null}function p(t){if(t.tag===31){var e=t.memoizedState;if(e===null&&(t=t.alternate,t!==null&&(e=t.memoizedState)),e!==null)return e.dehydrated}return null}function _(t){if(A(t)!==t)throw Error(f(188))}function R(t){var e=t.alternate;if(!e){if(e=A(t),e===null)throw Error(f(188));return e!==t?null:t}for(var l=t,a=e;;){var n=l.return;if(n===null)break;var u=n.alternate;if(u===null){if(a=n.return,a!==null){l=a;continue}break}if(n.child===u.child){for(u=n.child;u;){if(u===l)return _(n),t;if(u===a)return _(n),e;u=u.sibling}throw Error(f(188))}if(l.return!==a.return)l=n,a=u;else{for(var s=!1,o=n.child;o;){if(o===l){s=!0,l=n,a=u;break}if(o===a){s=!0,a=n,l=u;break}o=o.sibling}if(!s){for(o=u.child;o;){if(o===l){s=!0,l=u,a=n;break}if(o===a){s=!0,a=u,l=n;break}o=o.sibling}if(!s)throw Error(f(189))}}if(l.alternate!==a)throw Error(f(190))}if(l.tag!==3)throw Error(f(188));return l.stateNode.current===l?t:e}function Q(t){var e=t.tag;if(e===5||e===26||e===27||e===6)return t;for(t=t.child;t!==null;){if(e=Q(t),e!==null)return e;t=t.sibling}return null}function b(t,e,l,a,n,u){for(;t!==null;){if((t.tag===5||t.tag===27||t.tag===6)&&l(t,a,n,u)||(t.tag!==22||t.memoizedState===null)&&(e||t.tag!==5&&t.tag!==27)&&b(t.child,e,l,a,n,u))return!0;t=t.sibling}return!1}function H(t){for(t=t.return;t!==null;){if(t.tag===3||t.tag===5||t.tag===27)return t;t=t.return}return null}function Z(t){var e=!1;for(t=t.return;t!==null&&(t.tag===4&&(e=!0),!(t.tag===3||t.tag===5||t.tag===27));)t=t.return;return e}function W(t){var e=[null,null],l=H(t);return l===null||ot(e,t,l.child,{foundSelf:!1}),e}function ot(t,e,l,a){for(;l!==null;){if(l===e)a.foundSelf=!0;else if(l.tag===5||l.tag===27||l.tag===6){if(a.foundSelf)return t[1]=l,!0;t[0]=l}else if((l.tag!==22||l.memoizedState===null)&&ot(t,e,l.child,a))return!0;l=l.sibling}return!1}function rt(t){switch(t.tag){case 5:case 27:case 6:return t.stateNode;case 3:return t.stateNode.containerInfo;default:throw Error(f(559))}}var bt=null,it=null;function wt(t,e,l){return t===l?!0:t===e?(bt=t,!0):!1}function L(t,e,l){return t===l?(it=t,!1):t===e?(it!==null&&(bt=t),!0):!1}function ut(t){if(t===null)return null;do t=t===null?null:t.return;while(t&&t.tag!==5&&t.tag!==27&&t.tag!==3);return t||null}function O(t,e,l){for(var a=0,n=t;n;n=l(n))a++;n=0;for(var u=e;u;u=l(u))n++;for(;0<a-n;)t=l(t),a--;for(;0<n-a;)e=l(e),n--;for(;a--;){if(t===e||e!==null&&t===e.alternate)return t;t=l(t),e=l(e)}return null}var V=Object.assign,lt=Symbol.for("react.element"),St=Symbol.for("react.transitional.element"),Nt=Symbol.for("react.portal"),mt=Symbol.for("react.fragment"),Tt=Symbol.for("react.strict_mode"),q=Symbol.for("react.profiler"),ht=Symbol.for("react.consumer"),_t=Symbol.for("react.context"),G=Symbol.for("react.forward_ref"),Y=Symbol.for("react.suspense"),z=Symbol.for("react.suspense_list"),vt=Symbol.for("react.memo"),pt=Symbol.for("react.lazy"),ee=Symbol.for("react.activity"),fe=Symbol.for("react.legacy_hidden"),ne=Symbol.for("react.memo_cache_sentinel"),g=Symbol.for("react.view_transition"),w=Symbol.for("react.recoverable"),et=Symbol.iterator;function nt(t){return t===null||typeof t!="object"?null:(t=et&&t[et]||t["@@iterator"],typeof t=="function"?t:null)}var Mt=Symbol.for("react.client.reference");function Ct(t){if(t==null)return null;if(typeof t=="function")return t.$$typeof===Mt?null:t.displayName||t.name||null;if(typeof t=="string")return t;switch(t){case mt:return"Fragment";case q:return"Profiler";case Tt:return"StrictMode";case Y:return"Suspense";case z:return"SuspenseList";case ee:return"Activity";case g:return"ViewTransition"}if(typeof t=="object")switch(t.$$typeof){case Nt:return"Portal";case _t:return t.displayName||"Context";case ht:return(t._context.displayName||"Context")+".Consumer";case G:var e=t.render;return t=t.displayName,t||(t=e.displayName||e.name||"",t=t!==""?"ForwardRef("+t+")":"ForwardRef"),t;case vt:return e=t.displayName||null,e!==null?e:Ct(t.type)||"Memo";case pt:e=t._payload,t=t._init;try{return Ct(t(e))}catch{}}return null}var kt=Array.isArray,P=r.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE,ft=h.__DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE,Ne={pending:!1,data:null,method:null,actionLine truncated
at`)?" (<anonymous>)":-1<l.stack.indexOf("@")?"@unknown:0:0":""}return`
`+He+t+re}var fl=!1;function $l(t,e){if(!t||fl)return"";fl=!0;var l=Error.prepareStackTrace;Error.prepareStackTrace=void 0;try{var a={DetermineComponentFrameRoot:function(){try{if(e){var R=function(){throw Error()};if(Object.defineProperty(R.prototype,"props",{set:function(){throw Error()}}),typeof Reflect=="object"&&Reflect.construct){try{Reflect.construct(R,[])}catch(V){var p=V}Reflect.construct(t,[],R)}else{try{R.call()}catch(V){p=V}R=!1;try{var E=Object.getOwnPropertyDescriptor(t.prototype,"props");Object.defineProperty(t.prototype,"props",{configurable:!0,set:function(){throw Error()}}),R=!0,new t}finally{R&&(E!==void 0?Object.defineProperty(t.prototype,"props",E):delete t.prototype.props)}}}else{try{throw Error()}catch(V){p=V}(R=t())&&typeof R.catch=="function"&&R.catch(function(){})}}catch(V){if(V&&p&&typeof V.stack=="string")return[V.stack,p.stack]}return[null,null]}};a.DetermineComponentFrameRoot.displayName="DetermineComponentFrameRoot";var n=Object.getOwnPropertyDescriptor(a.DetermineComponentFrameRoot,"name");n&&n.configurable&&Object.defineProperty(a.DetermineComponentFrameRoot,"name",{value:"DetermineComponentFrameRoot"});var u=a.DetermineComponentFrameRoot(),s=u[0],o=u[1];if(s&&o){var m=s.split(`
`+xl+t+Dl}var me=!1;function il(t,e){if(!t||me)return"";me=!0;var l=Error.prepareStackTrace;Error.prepareStackTrace=void 0;try{var a={DetermineComponentFrameRoot:function(){try{if(e){var k=function(){throw Error()};if(Object.defineProperty(k.prototype,"props",{set:function(){throw Error()}}),typeof Reflect=="object"&&Reflect.construct){try{Reflect.construct(k,[])}catch(K){var S=K}Reflect.construct(t,[],k)}else{try{k.call()}catch(K){S=K}k=!1;try{var E=Object.getOwnPropertyDescriptor(t.prototype,"props");Object.defineProperty(t.prototype,"props",{configurable:!0,set:function(){throw Error()}}),k=!0,new t}finally{k&&(E!==void 0?Object.defineProperty(t.prototype,"props",E):delete t.prototype.props)}}}else{try{throw Error()}catch(K){S=K}(k=t())&&typeof k.catch=="function"&&k.catch(function(){})}}catch(K){if(K&&S&&typeof K.stack=="string")return[K.stack,S.stack]}return[null,null]}};a.DetermineComponentFrameRoot.displayName="DetermineComponentFrameRoot";var n=Object.getOwnPropertyDescriptor(a.DetermineComponentFrameRoot,"name");n&&n.configurable&&Object.defineProperty(a.DetermineComponentFrameRoot,"name",{value:"DetermineComponentFrameRoot"});var u=a.DetermineComponentFrameRoot(),s=u[0],o=u[1];if(s&&o){var m=s.split(`
`),j=o.split(`
`);for(n=a=0;a<m.length&&!m[a].includes("DetermineComponentFrameRoot");)a++;for(;n<j.length&&!j[n].includes("DetermineComponentFrameRoot");)n++;if(a===m.length||n===j.length)for(a=m.length-1,n=j.length-1;1<=a&&0<=n&&m[a]!==j[n];)n--;for(;1<=a&&0<=n;a--,n--)if(m[a]!==j[n]){if(a!==1||n!==1)do if(a--,n--,0>n||m[a]!==j[n]){var N=`
`+m[a].replace(" at new "," at ");return t.displayName&&N.includes("<anonymous>")&&(N=N.replace("<anonymous>",t.displayName)),N}while(1<=a&&0<=n);break}}}finally{fl=!1,Error.prepareStackTrace=l}return(l=t?t.displayName||t.name:"")?oe(l):""}function ya(t,e){switch(t.tag){case 26:case 27:case 5:return oe(t.type);case 16:return oe("Lazy");case 13:return t.child!==e&&e!==null?oe("Suspense Fallback"):oe("Suspense");case 19:return oe("SuspenseList");case 0:case 15:return $l(t.type,!1);case 11:return $l(t.type.render,!1);case 1:return $l(t.type,!0);case 31:return oe("Activity");case 30:return oe("ViewTransition");default:return""}}function kl(t){try{var e="",l=null;do e+=ya(t,l),l=t,t=t.return;while(t);return e}catch(a){return`
`);for(n=a=0;a<m.length&&!m[a].includes("DetermineComponentFrameRoot");)a++;for(;n<j.length&&!j[n].includes("DetermineComponentFrameRoot");)n++;if(a===m.length||n===j.length)for(a=m.length-1,n=j.length-1;1<=a&&0<=n&&m[a]!==j[n];)n--;for(;1<=a&&0<=n;a--,n--)if(m[a]!==j[n]){if(a!==1||n!==1)do if(a--,n--,0>n||m[a]!==j[n]){var C=`
`+m[a].replace(" at new "," at ");return t.displayName&&C.includes("<anonymous>")&&(C=C.replace("<anonymous>",t.displayName)),C}while(1<=a&&0<=n);break}}}finally{me=!1,Error.prepareStackTrace=l}return(l=t?t.displayName||t.name:"")?de(l):""}function kl(t,e){switch(t.tag){case 26:case 27:case 5:return de(t.type);case 16:return de("Lazy");case 13:return t.child!==e&&e!==null?de("Suspense Fallback"):de("Suspense");case 19:return de("SuspenseList");case 0:case 15:return il(t.type,!1);case 11:return il(t.type.render,!1);case 1:return il(t.type,!0);case 31:return de("Activity");case 30:return de("ViewTransition");default:return""}}function Fl(t){try{var e="",l=null;do e+=kl(t,l),l=t,t=t.return;while(t);return e}catch(a){return`
Error generating stack: `+a.message+`
`+a.stack}}var te=Object.prototype.hasOwnProperty,tl=i.unstable_scheduleCallback,Be=i.unstable_cancelCallback,Nl=i.unstable_shouldYield,dl=i.unstable_requestPaint,$t=i.unstable_now,Hl=i.unstable_getCurrentPriorityLevel,vn=i.unstable_ImmediatePriority,Va=i.unstable_UserBlockingPriority,be=i.unstable_NormalPriority,Il=i.unstable_LowPriority,ga=i.unstable_IdlePriority,fu=i.log,du=i.unstable_setDisableYieldValue,Pl=null,Ce=null;function ml(t){if(typeof fu=="function"&&du(t),Ce&&typeof Ce.setStrictMode=="function")try{Ce.setStrictMode(Pl,t)}catch{}}var Ae=Math.clz32?Math.clz32:vu,mu=Math.log,hu=Math.LN2;function vu(t){return t>>>=0,t===0?32:31-(mu(t)/hu|0)|0}var q=256,k=262144,I=4194304;function at(t){var e=t&42;if(e!==0)return e;switch(t&-t){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return t&-t;case 262144:case 524288:case 1048576:case 2097152:return t&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return t&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return t}}function qt(t,e,l){var a=t.pendingLanes;if(a===0)return 0;var n=0,u=t.suspendedLanes,s=t.pingedLanes;t=t.warmLanes;var o=a&134217727;return o!==0?(a=o&~u,a!==0?n=at(a):(s&=o,s!==0?n=at(s):l||(l=o&~t,l!==0&&(n=at(l))))):(o=a&~u,o!==0?n=at(o):s!==0?n=at(s):l||(l=a&~t,l!==0&&(n=at(l)))),n===0?0:e!==0&&e!==n&&(e&u)===0&&(u=n&-n,l=e&-e,u>=l||u===32&&(l&4194048)!==0)?e:n}function hl(t,e){return(t.pendingLanes&~(t.suspendedLanes&~t.pingedLanes)&e)===0}function w(t,e){(e&8)!==0&&(e|=e&32);var l=t.entangledLanes;if(l!==0)for(t=t.entanglements,l&=e;0<l;){var a=31-Ae(l),n=1<<a;e|=t[a],l&=~n}return e}function Z(t,e){switch(t){case 1:case 2:case 4:case 8:case 64:return e+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return e+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function K(){var t=I;return I<<=1,(I&62914560)===0&&(I=4194304),t}function Yt(t){for(var e=[],l=0;31>l;l++)e.push(t);return e}function St(t,e){t.pendingLanes|=e,e!==268435456&&(t.suspendedLanes=0,t.pingedLanes=0,t.warmLanes=0)}function qe(t,e,l,a,n,u){var s=t.pendingLanes;t.pendingLanes=l,t.suspendedLanes=0,t.pingedLanes=0,t.warmLanes=0,t.expiredLanes&=l,t.entangledLanes&=l,t.errorRecoveryDisabledLanes&=l,t.shellSuspendCounter=0;var o=t.entanglements,m=t.expirationTimes,j=t.hiddenUpdates;for(l=s&~l;0<l;){var N=31-Ae(l),R=1<<N;o[N]=0,m[N]=-1;var p=j[N];if(p!==null)for(j[N]=null,N=0;N<p.length;N++){var E=p[N];E!==null&&(E.lane&=-536870913)}l&=~R}a!==0&&Cl(t,a,0),u!==0&&n===0&&t.tag!==0&&(t.suspendedLanes|=u&~(s&~e))}function Cl(t,e,l){t.pendingLanes|=e,t.suspendedLanes&=~e;var a=31-Ae(e);t.entangledLanes|=e,t.entanglements[a]=t.entanglements[a]|1073741824|l&261930}function yn(t,e){var l=t.entangledLanes|=e;for(t=t.entanglements;l;){var a=31-Ae(l),n=1<<a;n&e|t[a]&e&&(t[a]|=e),l&=~n}}function ta(t,e){var l=e&-e;return l=(l&42)!==0?1:Bl(l),(l&(t.suspendedLanes|e))!==0?0:l}function Bl(t){switch(t){case 2:t=1;break;case 8:t=4;break;case 32:t=16;break;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:case 4194304:case 8388608:case 16777216:case 33554432:t=128;break;case 268435456:t=134217728;break;default:t=0}return t}function yu(t){return t&=-t,2<t?8<t?(t&134217727)!==0?32:268435456:8:2}function gu(){var t=ht.p;return t!==0?t:(t=window.event,t===void 0?32:Nh(t.type))}function Ke(t,e){var l=ht.p;try{return ht.p=t,e()}finally{ht.p=l}}var ne=Math.random().toString(36).slice(2),ye="__reactFiber$"+ne,Je="__reactProps$"+ne,gn="__reactContainer$"+ne,xo="__reactEvents$"+ne,Ph="__reactListeners$"+ne,tv="__reactHandles$"+ne,bo="__reactResources$"+ne,pu="__reactMarker$"+ne,fi="__reactLoad$"+ne;function di(t){delete t[ye],delete t[Je],delete t[Ph],delete t[tv]}function Za(t){var e;if(e=t[ye])return e;for(var l=t.parentNode;l;){if(e=l[gn]||l[ye]){if(l=e.alternate,e.child!==null||l!==null&&l.child!==null)for(t=ih(t);t!==null;){if(l=t[ye])return l;t=ih(t)}return e}t=l,l=t.parentNode}return null}function pn(t){if(t=t[ye]||t[gn]){var e=t.tag;if(e===5||e===6||e===13||e===31||e===26||e===27||e===3)return t}return null}function Su(t){var e=t.tag;if(e===5||e===26||e===27||e===6)return t.stateNode;throw Error(f(33))}function Sn(t){var e=t[bo];return e||(e=t[bo]={hoistableStyles:new Map,hoistableScripts:new Map}),e}function je(t){t[pu]=!0}function jo(t){t[fi]=void 0}var To=new Set,_o={};function Ka(t,e){xn(t,e),xn(t+"Capture",e)}function xn(t,eLine truncated
`).replace(G0,"")}function Ym(t,e){return e=qm(e),qm(t)===e}function Kt(t,e,l,a,n,u){switch(l){case"children":if(typeof a=="string")e==="body"||e==="textarea"&&a===""||jn(t,a);else if(typeof a=="number"||typeof a=="bigint")e!=="body"&&jn(t,""+a);else return;break;case"className":hi(t,"class",a);break;case"tabIndex":hi(t,"tabindex",a);break;case"dir":case"role":case"viewBox":case"width":case"height":hi(t,l,a);break;case"style":Uo(t,a,u);return;case"data":if(e!=="object"){hi(t,"data",a);break}case"src":case"href":if(a===""&&(e!=="a"||l!=="href")){t.removeAttribute(l);break}if(a==null||typeof a=="function"||typeof a=="symbol"||typeof a=="boolean"){t.removeAttribute(l);break}a=vi(a),t.setAttribute(l,a);break;case"action":case"formAction":if(typeof a=="function"){t.setAttribute(l,"javascript:throw new Error('A React form was unexpectedly submitted. If you called form.submit() manually, consider using form.requestSubmit() instead. If you\\'re trying to use event.stopPropagation() in a submit event handler, consider also calling event.preventDefault().')");break}else typeof u=="function"&&(l==="formAction"?(e!=="input"&&Kt(t,e,"name",n.name,n,null),Kt(t,e,"formEncType",n.formEncType,n,null),Kt(t,e,"formMethod",n.formMethod,n,null),Kt(t,e,"formTarget",n.formTarget,n,null)):(Kt(t,e,"encType",n.encType,n,null),Kt(t,e,"method",n.method,n,null),Kt(t,e,"target",n.target,n,null)));if(a==null||typeof a=="symbol"||typeof a=="boolean"){t.removeAttribute(l);break}a=vi(a),t.setAttribute(l,a);break;case"onClick":a!=null&&(t.onclick=ql);return;case"onScroll":a!=null&&Rt("scroll",t);return;case"onScrollEnd":a!=null&&Rt("scrollend",t);return;case"dangerouslySetInnerHTML":if(a!=null){if(typeof a!="object"||!("__html"in a))throw Error(f(61));if(l=a.__html,l!=null){if(n.children!=null)throw Error(f(60));(u!=null?u.__html:void 0)!==l&&(t.innerHTML=l)}}break;case"multiple":t.multiple=a&&typeof a!="function"&&typeof a!="symbol";break;case"muted":t.muted=a&&typeof a!="function"&&typeof a!="symbol";break;case"suppressContentEditableWarning":case"suppressHydrationWarning":case"defaultValue":case"defaultChecked":case"innerHTML":case"ref":break;case"autoFocus":break;case"xlinkHref":if(a==null||typeof a=="function"||typeof a=="boolean"||typeof a=="symbol"){t.removeAttribute("xlink:href");break}l=vi(a),t.setAttributeNS("http://www.w3.org/1999/xlink","xlink:href",l);break;case"contentEditable":case"spellCheck":case"draggable":case"value":case"autoReverse":case"externalResourcesRequired":case"focusable":case"preserveAlpha":a!=null&&typeof a!="function"&&typeof a!="symbol"?t.setAttribute(l,a):t.removeAttribute(l);break;case"inert":case"allowFullScreen":case"async":case"autoPlay":case"controls":case"credentialless":case"default":case"defer":case"disabled":case"disablePictureInPicture":case"disableRemotePlayback":case"formNoValidate":case"hidden":case"loop":case"noModule":case"noValidate":case"open":case"playsInline":case"readOnly":case"required":case"reversed":case"scoped":case"seamless":case"itemScope":a&&typeof a!="function"&&typeof a!="symbol"?t.setAttribute(l,""):t.removeAttribute(l);break;case"capture":case"download":a===!0?t.setAttribute(l,""):a!==!1&&a!=null&&typeof a!="function"&&typeof a!="symbol"?t.setAttribute(l,a):t.removeAttribute(l);break;case"cols":case"rows":case"size":case"span":a!=null&&typeof a!="function"&&typeof a!="symbol"&&!isNaN(a)&&1<=a?t.setAttribute(l,a):t.removeAttribute(l);break;case"rowSpan":case"start":a==null||typeof a=="function"||typeof a=="symbol"||isNaN(a)?t.removeAttribute(l):t.setAttribute(l,a);break;case"popover":Rt("beforetoggle",t),Rt("toggle",t),mi(t,"popover",a);break;case"xlinkActuate":ea(t,"http://www.w3.org/1999/xlink","xlink:actuate",a);break;case"xlinkArcrole":ea(t,"http://www.w3.org/1999/xlink","xlink:arcrole",a);break;case"xlinkRole":ea(t,"http://www.w3.org/1999/xlink","xlink:role",a);break;case"xlinkShow":ea(t,"http://www.w3.org/1999/xlink","xlink:show",a);break;case"xlinkTitle":ea(t,"http://www.w3.org/1999/xlink","xlink:title",a);break;case"xlinkType":ea(t,"http://www.w3.org/1999/xlink","xlink:type",a);break;case"xmlBase":ea(t,"http://www.w3.org/XML/1998/namespace","xml:base",a);break;case"xmlLang":ea(t,"http://www.w3.org/XML/1998/namespace","xml:lang",a);break;case"xmlSpace":ea(t,"http://www.w3.org/XML/1998/namespace","xml:space",a);break;case"is":mi(t,"is",a);break;case"innerText":case"textContent":return;default:if(!(2<l.length)||l[0]!=="o"&&l[0]!=="O"||l[1]!=="n"&&l[1]!=="N")l=iv.get(l)||l,mi(t,l,a);else return}Bt=!0}function Hr(t,e,l,a,n,u){switch(l){case"style":Uo(t,a,u);return;case"dangerouslySetInnerHTML":if(a!=null){if(typeof a!="object"||!("__html"in a))throw Error(f(61));if(l=a.__html,l!=null){if(n.children!=null)throw Error(f(60));(u!=null?u.__html:void 0)!==l&&(t.innerHTML=l)}}break;case"children":if(typeof a=="string")jn(t,a);else if(typeof a=="number"||typeof a=="bigint")jn(t,""+a);else return;break;case"onScroll":a!=null&&Rt("scroll",t);return;case"onScrollEnd":a!=null&&Rt("scroLine truncated
`;Yt&&(qe+=Yt+`
`),St&&(qe+=(Array.isArray(St)?St.join(`
`):String(St))+`
`),qe+=JSON.stringify(K,null,2).slice(0,4e3),_l(qe),se("已返回登录信息,见下方")}catch(K){se("发起失败:"+K.message)}},[]),I=d.useCallback(async()=>{const w=tt.trim();if(!w){L("请填要查配额的用户 id");return}try{const Z=await W(F.adminQuotaUser(w));L("用户 "+w.slice(0,8)+":本月用量 "+iu(Z.used_usd_micros)+" / 配额 "+iu(Z.quota_usd_micros)+(Z.remaining_usd_micros!==null&&Z.remaining_usd_micros!==void 0?"(剩余 "+iu(Z.remaining_usd_micros)+")":"")+" | 月份 "+(Z.month||""))}catch(Z){L(Dc(Z))}},[tt]),at=d.useCallback(async(w,Z)=>{const K=w.trim();if(!K){L("请填要设置配额的用户 id");return}let Yt;if(!Z.trim())Yt=null;else{const St=Number(Z.trim());if(!Number.isFinite(St)||St<0){L("配额金额无效:请输入 >= 0 的美元数,留空表示不限额");return}Yt=Math.round(St*1e6)}try{const St=await W(F.adminQuotas,"POST",{user_id:K,quota_usd_micros:Yt});L("配额已设置:用户 "+K.slice(0,8)+":本月用量 "+iu(St.used_usd_micros)+" / 配额 "+iu(St.quota_usd_micros)+(St.remaining_usd_micros!==null&&St.remaining_usd_micros!==void 0?"(剩余 "+iu(St.remaining_usd_micros)+")":"")+" | 月份 "+(St.month||""))}catch(St){L(Dc(St))}},[]),qt=d.useCallback(async()=>{if(window.confirm("确定手动触发 sweep 吗?超阈值的陈旧 running 任务会被标为 interrupted。"))try{const Z=(await W(F.adminSweep,"POST",{})).swept||[];L("sweep 完成,清理 "+Z.length+" 个任务"+(Z.length?":"+Z.join("、"):""))}catch(w){L(Dc(w))}},[]),hl=d.useCallback(async()=>{try{const w=await W(F.adminBackup,"POST",{});L("备份完成:backend="+w.backend+" | 路径 "+w.path+" | 大小 "+w.size_bytes+" 字节 | 任务 "+w.task_count+" 行")}catch(w){L(Dc(w))}},[]);return{keySaved:i,keyState:h,quotaText:y,healthText:M,adminGate:_,adminBody:Y,adminResult:H,adminUserId:tt,setAdminUserId:dt,projects:J,projectId:_t,setProjectId:Dt,projectMsg:Q,githubRepos:D,workers:et,workersSummary:Mt,workersError:Nt,members:nt,memberMsg:G,memberError:ut,skills:pt,apps:we,mcpServers:g,skillMsg:ct,skillError:Ot,mcpMsg:kt,mcpOauthMsg:ht,mcpOauthBox:La,envs:Ve,envCount:Qt,envMsg:Wl,envRepoLine:va,envRepoBar:Ul,envRepoMsg:El,envSelect:re,setEnvSelect:oe,models:fl,modelCustomVisible:ya,refreshHealth:Be,loadQuota:Nl,probeAdmin:dl,refreshProjects:$t,loadGithubRepos:Hl,loadRelayModels:vn,refreshWorkers:Va,refreshEnvs:be,refreshMembers:Il,refreshSkills:ga,saveKeyInput:fu,clearKeyInput:du,createProject:Pl,createEnv:Ce,archiveEnv:ml,saveEnvRepo:Ae,inviteMember:mu,changeMemberRole:hu,removeMember:vu,reloadMcp:q,mcpOauth:k,adminQuota:I,adminSetQuota:at,adminSweep:qt,adminBackup:hl}}const mo=20;function ag(){const[i,r]=d.useState([]),[h,f]=d.useState(""),[y,C]=d.useState(""),[M,S]=d.useState(""),[_,O]=d.useState(""),[Y,x]=d.useState(0),[H,L]=d.useState(0),[tt,dt]=d.useState({}),J=d.useRef(0),gt=d.useRef({status:"",executor:"",q:""});gt.current={status:y,executor:M,q:_};const _t=d.useRef(""),Dt=d.useCallback(async(nt,rt,G)=>{const A=nt!==void 0?nt:rt;if(!A){r([]),L(0),dt({});return}_t.current=A;const ut=G!==void 0?G:J.current,bt=gt.current;try{const pt=await W(F.tasksPage(A,mo,ut*mo,bt.status,bt.executor,bt.q));r(pt.tasks||[]),L(pt.total??(pt.tasks||[]).length),dt(pt.status_counts||{}),f("")}catch(pt){f("加载失败:"+pt.message)}},[]),Q=d.useCallback(nt=>{const rt=Math.max(0,nt);J.current=rt,x(rt)},[]),ft=d.useCallback(nt=>{Q(nt),Dt(_t.current||void 0,void 0,nt)},[Dt,Q]);d.useEffect(()=>{if(!_t.current)return;const nt=setTimeout(()=>{Dt(_t.current,void 0,0)},300);return()=>clearTimeout(nt)},[y,M,_]);const D=d.useCallback(nt=>{C(nt),Q(0)},[Q]),X=d.useCallback(nt=>{S(nt),Q(0)},[Q]),et=d.useCallback(nt=>{O(nt),Q(0)},[Q]),xt=d.useCallback(async nt=>{Q(0),await Dt(nt,void 0,0)},[Dt,Q]),Mt=Math.max(1,Math.ceil(H/mo)),mt=H===0?"共 0 个任务":"共 "+H+" 个任务,第 "+(Y+1)+" / "+Mt+" 页",Nt=i,B=d.useCallback(async(nt,rt)=>{const G={...nt};rt.length&&(G.upload_id=await tg(rt));const A=await W(F.tasksCreate,"POST",G);return"任务已建:"+pe(A.id)},[]);return{tasks:i,taskError:h,statusFilter:y,setStatusFilter:D,executorFilter:M,setExecutorFilter:X,searchText:_,setSearchText:et,filteredTasks:Nt,taskCountText:mt,refreshTasks:Dt,refreshTasksFirstPage:xt,page:Y,totalPages:Mt,taskTotal:H,taskStatusCounts:tt,goToPage:ft,dispatch:B}}function ng(i,r){const[h,f]=d.useState(null),[y,C]=d.useState(""),[M,S]=d.useState(""),[_,O]=d.useState(""),[Y,x]=d.useState(""),[H,L]=d.useState(void 0),[tt,dt]=d.useState(!1),[J,gt]=d.useState(""),[_t,Dt]=d.useState([]),[Q,ft]=d.useState([]),[D,X]=d.useState(""),[et,xt]=d.useState([]),[Mt,mt]=d.useState(""),[Nt,B]=d.useState(""),[nt,rt]=d.useState([]),[G,A]=d.useState(""),[ut,bt]=d.useState(""),[pt,Se]=d.useState(""),[we,Ne]=d.useState(""),[g,U]=d.useState([]),[ct,it]=d.useState([]),[Ot,Et]=d.useState(""),[kt,P]=d.useState([]),[ht,se]=d.useState(""),[La,_l]=d.useState(""),[Ve,ae]=d.useState(""),[Qt,ve]=d.useState(""),[Wl,xe]=d.useState([]),[va,Ze]=d.useState(""),Line truncated
`+a.stack}}var ue=Object.prototype.hasOwnProperty,jl=i.unstable_scheduleCallback,Ul=i.unstable_cancelCallback,wl=i.unstable_shouldYield,cl=i.unstable_requestPaint,Xt=i.unstable_now,bl=i.unstable_getCurrentPriorityLevel,Tl=i.unstable_ImmediatePriority,_l=i.unstable_UserBlockingPriority,Se=i.unstable_NormalPriority,Wl=i.unstable_LowPriority,$l=i.unstable_IdlePriority,Il=i.log,du=i.unstable_setDisableYieldValue,Pl=null,Ce=null;function sl(t){if(typeof Il=="function"&&du(t),Ce&&typeof Ce.setStrictMode=="function")try{Ce.setStrictMode(Pl,t)}catch{}}var Ae=Math.clz32?Math.clz32:vu,mu=Math.log,hu=Math.LN2;function vu(t){return t>>>=0,t===0?32:31-(mu(t)/hu|0)|0}var pa=256,Sa=262144,xa=4194304;function B(t){var e=t&42;if(e!==0)return e;switch(t&-t){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return t&-t;case 262144:case 524288:case 1048576:case 2097152:return t&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return t&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return t}}function U(t,e,l){var a=t.pendingLanes;if(a===0)return 0;var n=0,u=t.suspendedLanes,s=t.pingedLanes;t=t.warmLanes;var o=a&134217727;return o!==0?(a=o&~u,a!==0?n=B(a):(s&=o,s!==0?n=B(s):l||(l=o&~t,l!==0&&(n=B(l))))):(o=a&~u,o!==0?n=B(o):s!==0?n=B(s):l||(l=a&~t,l!==0&&(n=B(l)))),n===0?0:e!==0&&e!==n&&(e&u)===0&&(u=n&-n,l=e&-e,u>=l||u===32&&(l&4194048)!==0)?e:n}function $(t,e){return(t.pendingLanes&~(t.suspendedLanes&~t.pingedLanes)&e)===0}function N(t,e){(e&8)!==0&&(e|=e&32);var l=t.entangledLanes;if(l!==0)for(t=t.entanglements,l&=e;0<l;){var a=31-Ae(l),n=1<<a;e|=t[a],l&=~n}return e}function X(t,e){switch(t){case 1:case 2:case 4:case 8:case 64:return e+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return e+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function tt(){var t=xa;return xa<<=1,(xa&62914560)===0&&(xa=4194304),t}function Ut(t){for(var e=[],l=0;31>l;l++)e.push(t);return e}function yt(t,e){t.pendingLanes|=e,e!==268435456&&(t.suspendedLanes=0,t.pingedLanes=0,t.warmLanes=0)}function Wt(t,e,l,a,n,u){var s=t.pendingLanes;t.pendingLanes=l,t.suspendedLanes=0,t.pingedLanes=0,t.warmLanes=0,t.expiredLanes&=l,t.entangledLanes&=l,t.errorRecoveryDisabledLanes&=l,t.shellSuspendCounter=0;var o=t.entanglements,m=t.expirationTimes,j=t.hiddenUpdates;for(l=s&~l;0<l;){var C=31-Ae(l),k=1<<C;o[C]=0,m[C]=-1;var S=j[C];if(S!==null)for(j[C]=null,C=0;C<S.length;C++){var E=S[C];E!==null&&(E.lane&=-536870913)}l&=~k}a!==0&&rl(t,a,0),u!==0&&n===0&&t.tag!==0&&(t.suspendedLanes|=u&~(s&~e))}function rl(t,e,l){t.pendingLanes|=e,t.suspendedLanes&=~e;var a=31-Ae(e);t.entangledLanes|=e,t.entanglements[a]=t.entanglements[a]|1073741824|l&261930}function ja(t,e){var l=t.entangledLanes|=e;for(t=t.entanglements;l;){var a=31-Ae(l),n=1<<a;n&e|t[a]&e&&(t[a]|=e),l&=~n}}function oi(t,e){var l=e&-e;return l=(l&42)!==0?1:yu(l),(l&(t.suspendedLanes|e))!==0?0:l}function yu(t){switch(t){case 2:t=1;break;case 8:t=4;break;case 32:t=16;break;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:case 4194304:case 8388608:case 16777216:case 33554432:t=128;break;case 268435456:t=134217728;break;default:t=0}return t}function Fa(t){return t&=-t,2<t?8<t?(t&134217727)!==0?32:268435456:8:2}function ta(){var t=ft.p;return t!==0?t:(t=window.event,t===void 0?32:Nh(t.type))}function ea(t,e){var l=ft.p;try{return ft.p=t,e()}finally{ft.p=l}}var El=Math.random().toString(36).slice(2),he="__reactFiber$"+El,$t="__reactProps$"+El,xe="__reactContainer$"+El,fi="__reactEvents$"+El,Ph="__reactListeners$"+El,tv="__reactHandles$"+El,jo="__reactResources$"+El,gu="__reactMarker$"+El,di="__reactLoad$"+El;function mi(t){delete t[he],delete t[$t],delete t[Ph],delete t[tv]}function Wa(t){var e;if(e=t[he])return e;for(var l=t.parentNode;l;){if(e=l[xe]||l[he]){if(l=e.alternate,e.child!==null||l!==null&&l.child!==null)for(t=ih(t);t!==null;){if(l=t[he])return l;t=ih(t)}return e}t=l,l=t.parentNode}return null}function xn(t){if(t=t[he]||t[xe]){var e=t.tag;if(e===5||e===6||e===13||e===31||e===26||e===27||e===3)return t}return null}function pu(t){var e=t.tag;if(e===5||e===26||e===27||e===6)return t.stateNode;throw Error(f(33))}function jn(t){var e=t[jo];return e||(e=t[jo]={hoistableStyles:new Map,hoistableScripts:new Map}),e}function be(t){t[gu]=!0}function bo(t){t[di]=void 0}var To=new Set,_o={};function $a(t,e){bn(t,e),bn(t+"Capture",e)}function bn(t,e)Line truncated
`).replace(G0,"")}function Ym(t,e){return e=qm(e),qm(t)===e}function Zt(t,e,l,a,n,u){switch(l){case"children":if(typeof a=="string")e==="body"||e==="textarea"&&a===""||_n(t,a);else if(typeof a=="number"||typeof a=="bigint")e!=="body"&&_n(t,""+a);else return;break;case"className":vi(t,"class",a);break;case"tabIndex":vi(t,"tabindex",a);break;case"dir":case"role":case"viewBox":case"width":case"height":vi(t,l,a);break;case"style":ko(t,a,u);return;case"data":if(e!=="object"){vi(t,"data",a);break}case"src":case"href":if(a===""&&(e!=="a"||l!=="href")){t.removeAttribute(l);break}if(a==null||typeof a=="function"||typeof a=="symbol"||typeof a=="boolean"){t.removeAttribute(l);break}a=yi(a),t.setAttribute(l,a);break;case"action":case"formAction":if(typeof a=="function"){t.setAttribute(l,"javascript:throw new Error('A React form was unexpectedly submitted. If you called form.submit() manually, consider using form.requestSubmit() instead. If you\\'re trying to use event.stopPropagation() in a submit event handler, consider also calling event.preventDefault().')");break}else typeof u=="function"&&(l==="formAction"?(e!=="input"&&Zt(t,e,"name",n.name,n,null),Zt(t,e,"formEncType",n.formEncType,n,null),Zt(t,e,"formMethod",n.formMethod,n,null),Zt(t,e,"formTarget",n.formTarget,n,null)):(Zt(t,e,"encType",n.encType,n,null),Zt(t,e,"method",n.method,n,null),Zt(t,e,"target",n.target,n,null)));if(a==null||typeof a=="symbol"||typeof a=="boolean"){t.removeAttribute(l);break}a=yi(a),t.setAttribute(l,a);break;case"onClick":a!=null&&(t.onclick=Hl);return;case"onScroll":a!=null&&zt("scroll",t);return;case"onScrollEnd":a!=null&&zt("scrollend",t);return;case"dangerouslySetInnerHTML":if(a!=null){if(typeof a!="object"||!("__html"in a))throw Error(f(61));if(l=a.__html,l!=null){if(n.children!=null)throw Error(f(60));(u!=null?u.__html:void 0)!==l&&(t.innerHTML=l)}}break;case"multiple":t.multiple=a&&typeof a!="function"&&typeof a!="symbol";break;case"muted":t.muted=a&&typeof a!="function"&&typeof a!="symbol";break;case"suppressContentEditableWarning":case"suppressHydrationWarning":case"defaultValue":case"defaultChecked":case"innerHTML":case"ref":break;case"autoFocus":break;case"xlinkHref":if(a==null||typeof a=="function"||typeof a=="boolean"||typeof a=="symbol"){t.removeAttribute("xlink:href");break}l=yi(a),t.setAttributeNS("http://www.w3.org/1999/xlink","xlink:href",l);break;case"contentEditable":case"spellCheck":case"draggable":case"value":case"autoReverse":case"externalResourcesRequired":case"focusable":case"preserveAlpha":a!=null&&typeof a!="function"&&typeof a!="symbol"?t.setAttribute(l,a):t.removeAttribute(l);break;case"inert":case"allowFullScreen":case"async":case"autoPlay":case"controls":case"credentialless":case"default":case"defer":case"disabled":case"disablePictureInPicture":case"disableRemotePlayback":case"formNoValidate":case"hidden":case"loop":case"noModule":case"noValidate":case"open":case"playsInline":case"readOnly":case"required":case"reversed":case"scoped":case"seamless":case"itemScope":a&&typeof a!="function"&&typeof a!="symbol"?t.setAttribute(l,""):t.removeAttribute(l);break;case"capture":case"download":a===!0?t.setAttribute(l,""):a!==!1&&a!=null&&typeof a!="function"&&typeof a!="symbol"?t.setAttribute(l,a):t.removeAttribute(l);break;case"cols":case"rows":case"size":case"span":a!=null&&typeof a!="function"&&typeof a!="symbol"&&!isNaN(a)&&1<=a?t.setAttribute(l,a):t.removeAttribute(l);break;case"rowSpan":case"start":a==null||typeof a=="function"||typeof a=="symbol"||isNaN(a)?t.removeAttribute(l):t.setAttribute(l,a);break;case"popover":zt("beforetoggle",t),zt("toggle",t),hi(t,"popover",a);break;case"xlinkActuate":la(t,"http://www.w3.org/1999/xlink","xlink:actuate",a);break;case"xlinkArcrole":la(t,"http://www.w3.org/1999/xlink","xlink:arcrole",a);break;case"xlinkRole":la(t,"http://www.w3.org/1999/xlink","xlink:role",a);break;case"xlinkShow":la(t,"http://www.w3.org/1999/xlink","xlink:show",a);break;case"xlinkTitle":la(t,"http://www.w3.org/1999/xlink","xlink:title",a);break;case"xlinkType":la(t,"http://www.w3.org/1999/xlink","xlink:type",a);break;case"xmlBase":la(t,"http://www.w3.org/XML/1998/namespace","xml:base",a);break;case"xmlLang":la(t,"http://www.w3.org/XML/1998/namespace","xml:lang",a);break;case"xmlSpace":la(t,"http://www.w3.org/XML/1998/namespace","xml:space",a);break;case"is":hi(t,"is",a);break;case"innerText":case"textContent":return;default:if(!(2<l.length)||l[0]!=="o"&&l[0]!=="O"||l[1]!=="n"&&l[1]!=="N")l=iv.get(l)||l,hi(t,l,a);else return}Bt=!0}function Br(t,e,l,a,n,u){switch(l){case"style":ko(t,a,u);return;case"dangerouslySetInnerHTML":if(a!=null){if(typeof a!="object"||!("__html"in a))throw Error(f(61));if(l=a.__html,l!=null){if(n.children!=null)throw Error(f(60));(u!=null?u.__html:void 0)!==l&&(t.innerHTML=l)}}break;case"children":if(typeof a=="string")_n(t,a);else if(typeof a=="number"||typeof a=="bigint")_n(t,""+a);else return;break;case"onScroll":a!=null&&zt("scroll",t);return;case"onScrollEnd":a!=null&&zt("scroLine truncated
`;Ut&&(Wt+=Ut+`
`),yt&&(Wt+=(Array.isArray(yt)?yt.join(`
`):String(yt))+`
`),Wt+=JSON.stringify(tt,null,2).slice(0,4e3),Qe(Wt),Ne("已返回登录信息,见下方")}catch(tt){Ne("发起失败:"+tt.message)}},[]),xa=d.useCallback(async()=>{const N=W.trim();if(!N){Z("请填要查配额的用户 id");return}try{const X=await F(J.adminQuotaUser(N));Z("用户 "+N.slice(0,8)+":本月用量 "+su(X.used_usd_micros)+" / 配额 "+su(X.quota_usd_micros)+(X.remaining_usd_micros!==null&&X.remaining_usd_micros!==void 0?"(剩余 "+su(X.remaining_usd_micros)+")":"")+" | 月份 "+(X.month||""))}catch(X){Z(kc(X))}},[W]),B=d.useCallback(async(N,X)=>{const tt=N.trim();if(!tt){Z("请填要设置配额的用户 id");return}let Ut;if(!X.trim())Ut=null;else{const yt=Number(X.trim());if(!Number.isFinite(yt)||yt<0){Z("配额金额无效:请输入 >= 0 的美元数,留空表示不限额");return}Ut=Math.round(yt*1e6)}try{const yt=await F(J.adminQuotas,"POST",{user_id:tt,quota_usd_micros:Ut});Z("配额已设置:用户 "+tt.slice(0,8)+":本月用量 "+su(yt.used_usd_micros)+" / 配额 "+su(yt.quota_usd_micros)+(yt.remaining_usd_micros!==null&&yt.remaining_usd_micros!==void 0?"(剩余 "+su(yt.remaining_usd_micros)+")":"")+" | 月份 "+(yt.month||""))}catch(yt){Z(kc(yt))}},[]),U=d.useCallback(async()=>{if(window.confirm("确定手动触发 sweep 吗?超阈值的陈旧 running 任务会被标为 interrupted。"))try{const X=(await F(J.adminSweep,"POST",{})).swept||[];Z("sweep 完成,清理 "+X.length+" 个任务"+(X.length?":"+X.join("、"):""))}catch(N){Z(kc(N))}},[]),$=d.useCallback(async()=>{try{const N=await F(J.adminBackup,"POST",{});Z("备份完成:backend="+N.backend+" | 路径 "+N.path+" | 大小 "+N.size_bytes+" 字节 | 任务 "+N.task_count+" 行")}catch(N){Z(kc(N))}},[]);return{keySaved:i,keyState:h,quotaText:y,healthText:M,adminGate:_,adminBody:Q,adminResult:H,adminUserId:W,setAdminUserId:ot,projects:rt,projectId:it,setProjectId:wt,projectMsg:L,githubRepos:O,workers:lt,workersSummary:Nt,workersError:Tt,members:ht,memberMsg:G,memberError:z,skills:pt,apps:fe,mcpServers:g,skillMsg:et,skillError:Mt,mcpMsg:kt,mcpOauthMsg:ft,mcpOauthBox:Ja,envs:Xe,envCount:Gt,envMsg:Jl,envRepoLine:ya,envRepoBar:Rl,envRepoMsg:ga,envSelect:Dl,setEnvSelect:de,models:me,modelCustomVisible:kl,refreshHealth:Ul,loadQuota:wl,probeAdmin:cl,refreshProjects:Xt,loadGithubRepos:bl,loadRelayModels:Tl,refreshWorkers:_l,refreshEnvs:Se,refreshMembers:Wl,refreshSkills:$l,saveKeyInput:Il,clearKeyInput:du,createProject:Pl,createEnv:Ce,archiveEnv:sl,saveEnvRepo:Ae,inviteMember:mu,changeMemberRole:hu,removeMember:vu,reloadMcp:pa,mcpOauth:Sa,adminQuota:xa,adminSetQuota:B,adminSweep:U,adminBackup:$}}const ho=20;function ag(){const[i,r]=d.useState([]),[h,f]=d.useState(""),[y,A]=d.useState(""),[M,p]=d.useState(""),[_,R]=d.useState(""),[Q,b]=d.useState(""),[H,Z]=d.useState(0),[W,ot]=d.useState(0),[rt,bt]=d.useState({}),it=d.useRef(0),wt=d.useRef({status:"",executor:"",q:"",kind:""});wt.current={status:y,executor:M,q:Q,kind:_};const L=d.useRef(""),ut=d.useCallback(async(Y,z,vt)=>{const pt=Y!==void 0?Y:z;if(!pt){r([]),ot(0),bt({});return}L.current=pt;const ee=vt!==void 0?vt:it.current,fe=wt.current;try{const ne=await F(J.tasksPage(pt,ho,ee*ho,fe.status,fe.executor,fe.q,fe.kind));r(ne.tasks||[]),ot(ne.total??(ne.tasks||[]).length),bt(ne.status_counts||{}),f("")}catch(ne){f("加载失败:"+ne.message)}},[]),O=d.useCallback(Y=>{const z=Math.max(0,Y);it.current=z,Z(z)},[]),V=d.useCallback(Y=>{O(Y),ut(L.current||void 0,void 0,Y)},[ut,O]);d.useEffect(()=>{if(!L.current)return;const Y=setTimeout(()=>{ut(L.current,void 0,0)},300);return()=>clearTimeout(Y)},[y,M,Q,_]);const lt=d.useCallback(Y=>{A(Y),O(0)},[O]),St=d.useCallback(Y=>{p(Y),O(0)},[O]),Nt=d.useCallback(Y=>{R(Y),O(0)},[O]),mt=d.useCallback(Y=>{b(Y),O(0)},[O]),Tt=d.useCallback(async Y=>{O(0),await ut(Y,void 0,0)},[ut,O]),q=Math.max(1,Math.ceil(W/ho)),ht=W===0?"共 0 个任务":"共 "+W+" 个任务,第 "+(H+1)+" / "+q+" 页",_t=i,G=d.useCallback(async(Y,z)=>{const vt={...Y};z.length&&(vt.upload_id=await tg(z));const pt=await F(J.tasksCreate,"POST",vt);return"任务已建:"+se(pt.id)},[]);return{tasks:i,taskError:h,statusFilter:y,setStatusFilter:lt,executorFilter:M,setExecutorFilter:St,kindFilter:_,setKindFilter:Nt,searchText:Q,setSearchText:mt,filteredTasks:_t,taskCountText:ht,refreshTasks:ut,refreshTasksFirstPage:Tt,page:H,totalPages:q,taskTotal:W,taskStatusCounts:rt,goToPage:V,dispatch:G}}function ng(i,r){const[h,f]=d.useState(null),[y,A]=d.useState(""),[M,p]=d.useState(""),[_,R]=d.useState(""),[Q,b]=d.useState(""),[H,Z]=d.useState(void 0),[W,ot]=d.useState(!1),[rt,bt]=d.useState(""),[it,wt]=d.useState([]),[L,ut]=d.useState([]),[O,V]=d.useState([]),[lt,St]=d.useState(""),[Nt,mt]=d.useState([]),[Tt,q]=d.useState(""),[ht,_t]=d.useState(""),[G,Y]=d.useState([]),[z,vt]=d.useState(""),[pt,ee]=d.useState(""),[fe,ne]=d.useState(""),[g,w]=d.useState(""),[et,nt]=d.useState([]),[Mt,Ct]=d.useState([]),[kt,P]=d.useState(""),[ft,Ne]=d.useState([]),[Ja,Qe]=d.useStatLine truncated
`);yn=Bl.pop()||"";for(const yu of Bl){const gu=yu.split(`
`).filter(Ke=>Ke.indexOf("data: ")===0).map(Ke=>Ke.slice(6)).join(`
`);if(gu)try{const Ke=JSON.parse(gu);if(Ke.type==="snapshot")at=Ke.entries||[],Object.keys(qt).forEach(ne=>delete qt[ne]),at.forEach((ne,ye)=>{ne.id&&(qt[ne.id]=ye)}),hl=Ke.note||"",rt([...at]),A(hl),$l(q);else if(Ke.type==="entry"){const ne=Ke.entry;ne.id&&ne.id in qt?at[qt[ne.id]]=ne:(ne.id&&(qt[ne.id]=at.length),at.push(ne)),rt([...at]),$l(q)}else Ke.type==="done"&&(w=!0,Z=Ke.status,bt(Z))}catch{}}if(w)break}}catch(K){if(K&&K.name==="AbortError")return;A("链路流中断:"+K.message);return}w&&r()},[oe,$l,r]),be=d.useCallback(async q=>{oe();try{const k=await W(F.task(q));f(k);let I="";k.result_text&&(I+=`=== result ===
`+k.result_text.slice(0,8e3)+`
`),k.error_text&&(I+=`=== error ===
`+k.error_text.slice(0,8e3)+`
`),I||(I="(暂无 result/error 输出)"),C(I),x(""),L(void 0),kl(q),dt(!1),gt(""),se(""),ve(""),vn(k.status);const at=i(k.project_id),qt=at==="maintainer"||at==="owner",hl=at==="contributor"||qt,w=su.includes(k.status),Z=[];["failed","interrupted","merge-failed"].includes(k.status)&&qt||Z.push("重试:不可用——仅 failed/interrupted/merge-failed 可重试"+(qt?"":",且仅 maintainer 可重试")),!w&&hl||Z.push("取消:不可用——"+(w?"终态任务不可取消":"仅项目成员(contributor 及以上)可取消")),k.status==="completed"&&qt||Z.push("合并:不可用——仅 completed 可合并"+(qt?"":",且仅 maintainer 可合并")),k.status==="merged"&&qt||Z.push("提 PR:不可用——仅 merged 可提 PR"+(qt?"":",且仅 maintainer 可提 PR")),O(Z.join(";")),S(""),Va(q,k.status),fl(q),ya(k),tl(q),te(q),dl(q),$t(q),Hl(q),Be(q),Nl(q)}catch(k){f(null),C("加载失败:"+k.message)}},[oe,Va,fl,ya,tl,te,dl,$t,Hl,Be,Nl,kl,i]),Il=d.useCallback(async()=>{const q=re.current;if(q){try{const k=await W(F.task(q.id));f(k);let I="";k.result_text&&(I+=`=== result ===
`+k.result_text.slice(0,8e3)+`
`),k.error_text&&(I+=`=== error ===
`+k.error_text.slice(0,8e3)+`
`),C(I||"(暂无 result/error 输出)")}catch{}te(q.id),tl(q.id),Be(q.id),Nl(q.id),ya(q),fl(q.id),dl(q.id),$t(q.id),Hl(q.id),kl(q.id)}},[te,tl,Be,Nl,ya,fl,dl,$t,Hl,kl]),ga=d.useCallback(async q=>{const k=re.current;if(!k)return;const I={cancel:"确定取消该任务吗?",merge:"确定把 completed 任务标为 merged 吗?",pr:"确定为该任务提 PR 吗?(会在仓库真实建 PR)"};if(!(I[q]&&!window.confirm(I[q])))try{const at=await W(F.taskAction(k.id,q),"POST",{});S(q+" 已提交:"+JSON.stringify(at).slice(0,300)),r(),await be(k.id)}catch(at){S(q+" 失败:"+at.message)}},[be,r]),fu=d.useCallback(async()=>{const q=re.current;if(q)try{const k=await W(F.taskCost(q.id));x(JSON.stringify(k,null,2).slice(0,4e3))}catch(k){x("查成本失败:"+k.message)}},[]),du=d.useCallback(async q=>{const k=re.current;if(k){if(!q.trim()){gt("分支消息必填");return}if(!su.includes(k.status)){gt("仅终态任务可分支");return}try{const I=await W(F.taskFork(k.id),"POST",{message:q.trim()});gt("分支任务已建:"+I.id.slice(0,8)),r()}catch(I){gt("分支失败:"+I.message)}}},[r]),Pl=d.useCallback(async q=>{const k=re.current;if(k)try{await W(F.taskReviews(k.id),"POST",q),mt("评审已登记(requested)"),await Be(k.id)}catch(I){mt("登记评审需求失败:"+I.message)}},[Be]),Ce=d.useCallback(q=>{const k=re.current;if(!k)return;const I=window.prompt("输入该条评审的 findings(将写回记录并标为 completed):");I!==null&&W(F.taskReviewFindings(k.id,q),"POST",{findings:I}).then(()=>void Be(k.id)).catch(at=>mt("写 findings 失败:"+at.message))},[Be]),ml=d.useCallback((q,k)=>{const I=re.current;if(!I)return;const at=k==="approved"?"approve":"deny";W(F.taskApprovalDecision(I.id,q,at),"POST",{}).then(()=>{Et("已记录"+(k==="approved"?"允许":"拒绝")+"(当前版本只留回执,不拦截实时行为)"),Nl(I.id)}).catch(qt=>Et("决定失败:"+qt.message))},[Nl]),Ae=d.useCallback(async(q,k,I)=>{const at=re.current;if(at){if(!k.trim()){se("目标任务 id 不能为空");return}if(!I.trim()){se("消息正文不能为空");return}try{const qt=await W(F.taskMessagesTo(k.trim()),"POST",{from_task_id:at.id,body:I.trim(),mode:q});se("已发送("+qt.delivery+")"),await dl(at.id)}catch(qt){se("发送失败:"+qt.message)}}},[dl]),mu=d.useCallback((q,k)=>{const I=re.current;if(!I)return;if(!["completed","failed","interrupted"].includes(I.status)){ve("仅 completed/failed/interrupted 任务可操作线程(merged/merge-failed 不可改,服务端同样 409)");return}const at={};if(q==="rename"){if(!k.trim()){ve("新线程名不能为空");return}at.name=k.trim()}W(F.taskThreadAction(I.id,q),"POST",at).then(qt=>{ve(q+" 已提交:"+JSON.stringify(qt).slice(0,300)),$t(I.id),Hl(I.id)}).catch(qt=>ve(q+" 失败:"+qt.message))},[$t,Hl]),hu=d.useCallback(async q=>{const k=re.current;if(!k)return;const I=q.trim();if(!I){Ze("消息不能为空");return}try{su.includes(k.status)&&k.status!=="merged"?(await W(F.taskContinue(k.id),"POST",{message:I}),Ze("已重新排队,同一会话继续"),r(),await be(k.id)):["queued","running"].includes(k.status)?(await W(F.taskFollowups(k.id),"POST",{body:I,mode:"direct"}),Ze("已直接发送"),await te(k.id),await tl(k.id)):Ze("merged/merge-failed 任务不可继续,也不可发后续消息")}catch(at){Ze("发送失败:"+at.message)}},[be,r,te,tl]),vu=d.useCallback(async()=>{const q=re.current;if(!q){Ne("先选一个任务");return}try{const k=await W(F.taskTrace(q.id)),I=new Blob([JSON.stringify({task_id:q.id,status:k.status,note:k.note||"",count:(k.entries||[]).length,entries:k.entries||[]},null,2)],{type:"application/json"}),at=document.createElement("a");at.href=URL.createObjectURL(I),at.download="trace-"+q.id.slice(0,8)+".json",document.body.appendChild(at),at.click(),at.remove(),setTimeout(()=>URL.revokeObjectURL(at.href),1e3),Ne("已下载完整链路 "+(k.entries||[]).length+" 条")}catch(k){Ne("下载失败:"+k.message)}},[]);return d.useEffect(()=>()=>oe(),[oe]),{currentTask:h,detailBody:y,actionMsg:M,actionReasons:_,costBox:Y,costSummary:H,forkVisible:tt,setForkVisible:dt,forkMsg:J,coordChildren:_t,agents:Q,agentsNote:D,reviews:et,reviewMsg:Mt,reviewError:Nt,traceEntries:nt,traceNote:G,traceDone:ut,traceKind:pt,setTraceKind:Se,traceMsg:we,followups:g,approvals:ct,approvalMsg:Ot,peerMessages:kt,peerMsg:ht,threadText:La,threadGateNote:Ve,threadMsg:Qt,threadList:Wl,chatMsg:va,chatHistory:Ul,showDetail:be,refreshDetailLists:Il,doAction:ga,taskCost:fu,forkTask:du,postReview:Pl,submitFindings:Ce,decideApproval:ml,postPeer:Ae,threadAction:mu,sendChatMessage:hu,downloadTrace:vu,refreshReviews:()=>void Be(),refreshApprovals:()=>void Nl()}}function ug(i){const[r,h]=d.useState([]),[f,y]=d.useState(""),[C,M]=d.useState(null),[S,_]=d.useState(""),[O,Y]=d.useState(null),[x,H]=d.useState(""),L=d.useCallback(async Q=>{const ft=Q!==void 0?Q:i();if(!ft){h([]);return}try{const D=await W(F.projectAutomations(ft));h(D.automations||[])}catLine truncated
(链路摘要已截断至 300 字,完整回复见右侧任务结果)`:f,label:"助手"}}if(i.kind==="exec"||i.kind==="file"||i.kind==="tool"||i.kind==="call"){const f=i.kind==="exec"?"命令":i.kind==="file"?"文件":i.kind==="tool"?"工具":"调用";return{id:h,role:"tool",kind:i.kind,text:yo(i),label:f}}return i.kind==="plan"?{id:h,role:"status",kind:i.kind,text:i.text||""}:i.kind==="reasoning"?{id:h,role:"status",kind:i.kind,text:"思考:"+(i.summary||"")}:i.kind==="agent-start"?{id:h,role:"status",kind:i.kind,text:"子代理开始:"+(i.name||"")+(i.prompt?"("+i.prompt+")":"")}:i.kind==="agent-done"?{id:h,role:"status",kind:i.kind,text:"子代理结束:"+(i.name||"")+" "+(i.status||"")}:{id:h,role:"status",kind:i.kind,text:yo(i)}}function pg(){const i=Lt(),r=i.currentTask,h=d.useMemo(()=>{if(!r)return[];let _;if(i.chatHistory){_=i.chatHistory.messages.map(tt=>({id:tt.id,role:tt.role,text:tt.text,label:tt.label,kind:tt.kind}));const H=new Set(_.map(tt=>tt.id)),L=i.traceEntries.filter(tt=>tt.id&&!H.has(tt.id)&&!String(tt.id).startsWith("fu-")).map((tt,dt)=>Lh(tt,dt));_=[..._,...L]}else{const H=r.prompt?[{id:"task-prompt",role:"user",text:r.prompt}]:[],L=i.traceEntries.filter(dt=>!String(dt.id||"").startsWith("fu-")).map((dt,J)=>Lh(dt,J)),tt=i.followups.map((dt,J)=>({id:"followup-"+J,role:"user",text:String(dt.body||""),label:"后续消息"}));_=[...H,...L,...tt]}const O=i.traceKind,Y=O?_.filter(H=>H.role==="user"||!H.kind||H.kind===O):_,x=i.traceDone?[{id:"trace-done",role:"status",text:"链路已结束("+i.traceDone+")"}]:[];return[...Y,...x]},[r,i.chatHistory,i.traceEntries,i.followups,i.traceKind,i.traceDone]);if(!r)return c.jsx("div",{className:"muted",children:"点任务行查看详情后,这里显示继续会话"});const f=su.includes(r.status),y=["queued","running"].includes(r.status),C=f||y,M=f?"终态任务:发送走继续接口(同一会话再跑一轮)":y?"运行中/排队中:发送走后续消息直接发送(跑完当前轮后优先执行)":"merged/merge-failed 任务不可继续,也不可发后续消息",S=i.chatHistory?i.chatHistory.legacy_continue_count??null:null;return c.jsxs("div",{className:"chat-column",children:[c.jsx("div",{className:"muted",children:"实时对话"}),c.jsxs(At,{children:[c.jsxs("select",{"aria-label":"链路类型",value:i.traceKind,onChange:_=>i.setTraceKind(_.target.value),children:[c.jsx("option",{value:"",children:"链路类型:全部"}),["exec","file","call","tool","message","reasoning","plan","agent-start","agent-done"].map(_=>c.jsx("option",{value:_,children:_==="exec"?"命令":_==="file"?"文件":_==="call"?"调用":_==="tool"?"工具":_==="message"?"消息":_==="reasoning"?"思考":_==="plan"?"计划":"子代理"},_))]}),c.jsx("button",{onClick:i.downloadTrace,children:"下载完整链路"}),c.jsx("span",{className:"muted",children:i.traceMsg})]}),i.traceNote?c.jsx("div",{children:c.jsx("span",{className:"err",children:i.traceNote})}):null,S!==null&&S>0?c.jsxs("div",{className:"muted",children:["此前有 ",S," 条继续消息发生在持久化之前,未入库,上面历史不完整;部署后新发的继续消息会正常显示。"]}):null,i.chatHistory&&i.chatHistory.trace_note?c.jsx("div",{className:"muted",children:i.chatHistory.trace_note}):null,c.jsx(yg,{messages:h,empty:"(暂无会话消息)"}),c.jsx(gg,{disabled:!C,placeholder:"消息",onSend:_=>void i.sendChatMessage(_)}),c.jsxs("div",{className:"muted",children:[M,"。",i.chatMsg]}),c.jsx("div",{className:"muted",children:"对话时间线:首条是本任务 prompt(用户);助手回复、命令/文件/工具/调用、计划/思考/子代理开始结束都在同一列里,不再另起一份链路名单。流式进行中时新条目实时追加;你在底部时自动跟随,往上翻时暂停跟随,点“回到最新”恢复。"})]})}function Sg(){return Lt().currentTask?c.jsx("div",{className:"conversation",children:c.jsx(pg,{})}):c.jsx("div",{className:"conversation",children:c.jsx("div",{className:"muted",children:"在任务列表里点一条任务,对话和详情就切到该任务;也可以在上面派发一个新任务。"})})}function xg(){const i=Lt(),r=i.currentTask,[h,f]=d.useState("");if(!r)return null;const y=su.includes(r.status),C=["failed","interrupted","merge-failed"].includes(r.status);return c.jsxs(c.Fragment,{children:[c.jsxs(At,{children:[c.jsx("button",{disabled:!C,title:C?"":"仅 failed/interrupted/merge-failed 可重试",onClick:()=>i.doAction("retry"),children:"重试"}),c.jsx("button",{className:"danger",disabled:y,title:y?"终态任务不可取消":"",onClick:()=>i.doAction("cancel"),children:"取消"}),c.jsx("button",{disabled:r.status!=="completed",title:r.status!=="completed"?"仅 completed 可合并":"",onClick:()=>i.doAction("merge"),children:"合并(completed → merged)"}),c.jsx("button",{disabled:r.status!=="merged",title:r.status!=="merged"?"仅 merged 可提 PR":"",onClick:()=>i.doAction("pr"),childrLine truncated
`);Fa=ea.pop()||"";for(const El of ea){const he=El.split(`
`).filter($t=>$t.indexOf("data: ")===0).map($t=>$t.slice(6)).join(`
`);if(he)try{const $t=JSON.parse(he);if($t.type==="snapshot")N=$t.entries||[],Object.keys(X).forEach(xe=>delete X[xe]),N.forEach((xe,fi)=>{xe.id&&(X[xe.id]=fi)}),tt=$t.note||"",Y([...N]),vt(tt),Fl(B);else if($t.type==="entry"){const xe=$t.entry;xe.id&&xe.id in X?N[X[xe.id]]=xe:(xe.id&&(X[xe.id]=N.length),N.push(xe)),Y([...N]),Fl(B)}else $t.type==="done"&&(Ut=!0,yt=$t.status,ee(yt))}catch{}}if(Ut)break}}catch(Wt){if(Wt&&Wt.name==="AbortError")return;vt("链路流中断:"+Wt.message);return}Ut&&r()},[il,Fl,r]),Il=d.useCallback(async B=>{il();try{const U=await F(J.task(B));f(U);let $="";U.result_text&&($+=`=== result ===
`+U.result_text.slice(0,8e3)+`
`),U.error_text&&($+=`=== error ===
`+U.error_text.slice(0,8e3)+`
`),$||($="(暂无 result/error 输出)"),A($),b(""),Z(void 0),Ul(B),ot(!1),bt(""),Qe(""),re(""),Wl(U.status);const N=i(U.project_id),X=N==="maintainer"||N==="owner",tt=N==="contributor"||X,Ut=ou.includes(U.status),yt=[];["failed","interrupted","merge-failed"].includes(U.status)&&X||yt.push("重试:不可用——仅 failed/interrupted/merge-failed 可重试"+(X?"":",且仅 maintainer 可重试")),!Ut&&tt||yt.push("取消:不可用——"+(Ut?"终态任务不可取消":"仅项目成员(contributor 及以上)可取消")),U.status==="completed"&&X||yt.push("合并:不可用——仅 completed 可合并"+(X?"":",且仅 maintainer 可合并")),U.status==="merged"&&X||yt.push("提 PR:不可用——仅 merged 可提 PR"+(X?"":",且仅 maintainer 可提 PR")),R(yt.join(";")),p(""),$l(B,U.status),kl(B),ue(U),jl(U),cl(B),wl(B),Tl(B),_l(B),Se(B),Xt(B),bl(B)}catch(U){f(null),A("加载失败:"+U.message)}},[il,$l,kl,ue,jl,cl,wl,Tl,_l,Se,Xt,bl,Ul,i]),du=d.useCallback(async()=>{const B=me.current;if(B){try{const U=await F(J.task(B.id));f(U);let $="";U.result_text&&($+=`=== result ===
`+U.result_text.slice(0,8e3)+`
`),U.error_text&&($+=`=== error ===
`+U.error_text.slice(0,8e3)+`
`),A($||"(暂无 result/error 输出)")}catch{}wl(B.id),cl(B.id),Xt(B.id),bl(B.id),ue(B),jl(B),kl(B.id),Tl(B.id),_l(B.id),Se(B.id),Ul(B.id)}},[wl,cl,Xt,bl,ue,jl,kl,Tl,_l,Se,Ul]),Pl=d.useCallback(async B=>{const U=me.current;if(!U)return;const $={cancel:"确定取消该任务吗?",merge:"确定把 completed 任务标为 merged 吗?",pr:"确定为该任务提 PR 吗?(会在仓库真实建 PR)"};if(!($[B]&&!window.confirm($[B])))try{const N=await F(J.taskAction(U.id,B),"POST",{});p(B+" 已提交:"+JSON.stringify(N).slice(0,300)),r(),await Il(U.id)}catch(N){p(B+" 失败:"+N.message)}},[Il,r]),Ce=d.useCallback(async()=>{const B=me.current;if(B)try{const U=await F(J.taskCost(B.id));b(JSON.stringify(U,null,2).slice(0,4e3))}catch(U){b("查成本失败:"+U.message)}},[]),sl=d.useCallback(async B=>{const U=me.current;if(U){if(!B.trim()){bt("分支消息必填");return}if(!ou.includes(U.status)){bt("仅终态任务可分支");return}try{const $=await F(J.taskFork(U.id),"POST",{message:B.trim()});bt("分支任务已建:"+$.id.slice(0,8)),r()}catch($){bt("分支失败:"+$.message)}}},[r]),Ae=d.useCallback(async B=>{const U=me.current;if(U)try{await F(J.taskReviews(U.id),"POST",B),q("评审已登记(requested)"),await Xt(U.id)}catch($){q("登记评审需求失败:"+$.message)}},[Xt]),mu=d.useCallback(B=>{const U=me.current;if(!U)return;const $=window.prompt("输入该条评审的 findings(将写回记录并标为 completed):");$!==null&&F(J.taskReviewFindings(U.id,B),"POST",{findings:$}).then(()=>void Xt(U.id)).catch(N=>q("写 findings 失败:"+N.message))},[Xt]),hu=d.useCallback((B,U)=>{const $=me.current;if(!$)return;const N=U==="approved"?"approve":"deny";F(J.taskApprovalDecision($.id,B,N),"POST",{}).then(()=>{P("已记录"+(U==="approved"?"允许":"拒绝")+"(当前版本只留回执,不拦截实时行为)"),bl($.id)}).catch(X=>P("决定失败:"+X.message))},[bl]),vu=d.useCallback(async(B,U,$)=>{const N=me.current;if(N){if(!U.trim()){Qe("目标任务 id 不能为空");return}if(!$.trim()){Qe("消息正文不能为空");return}try{const X=await F(J.taskMessagesTo(U.trim()),"POST",{from_task_id:N.id,body:$.trim(),mode:B});Qe("已发送("+X.delivery+")"),await Tl(N.id)}catch(X){Qe("发送失败:"+X.message)}}},[Tl]),pa=d.useCallback((B,U)=>{const $=me.current;if(!$)return;if(!["completed","failed","interrupted"].includes($.status)){re("仅 completed/failed/interrupted 任务可操作线程(merged/merge-failed 不可改,服务端同样 409)");return}const N={};if(B==="rename"){if(!U.trim()){re("新线程名不能为空");return}N.name=U.trim()}F(J.taskThreadAction($.id,B),"POST",N).then(X=>{re(B+" 已提交:"+JSON.stringify(X).slice(0,300)),_l($.id),Se($.id)}).catch(X=>re(B+" 失败:"+X.message))},[_l,Se]),Sa=d.useCallback(async B=>{const U=me.current;if(!U)return;const $=B.trim();if(!$){Fe("消息不能为空");return}try{ou.includes(U.status)&&U.status!=="merged"?(await F(J.taskContinue(U.id),"POST",{message:$}),Fe("已重新排队,同一会话继续"),r(),await Il(U.id)):["queued","running"].includes(U.status)?(await F(J.taskFollowups(U.id),"POST",{body:$,mode:"direct"}),Fe("已直接发送"),await wl(U.id),await cl(U.id)):Fe("merged/merge-failed 任务不可继续,也不可发后续消息")}catch(N){Fe("发送失败:"+N.message)}},[Il,r,wl,cl]),xa=d.useCallback(async()=>{const B=me.current;if(!B){w("先选一个任务");return}try{const U=await F(J.taskTrace(B.id)),$=new Blob([JSON.stringify({task_id:B.id,status:U.status,note:U.note||"",count:(U.entries||[]).length,entries:U.entries||[]},null,2)],{type:"application/json"}),N=document.createElement("a");N.href=URL.createObjectURL($),N.download="trace-"+B.id.slice(0,8)+".json",document.body.appendChild(N),N.click(),N.remove(),setTimeout(()=>URL.revokeObjectURL(N.href),1e3),w("已下载完整链路 "+(U.entries||[]).length+" 条")}catch(U){w("下载失败:"+U.message)}},[]);return d.useEffect(()=>()=>il(),[il]),{currentTask:h,detailBody:y,actionMsg:M,actionReasons:_,costBox:Q,costSummary:H,forkVisible:W,setForkVisible:ot,forkMsg:rt,coordChildren:it,sideThreads:L,agents:O,agentsNote:lt,reviews:Nt,reviewMsg:Tt,reviewError:ht,traceEntries:G,traceNote:z,traceDone:pt,traceKind:fe,setTraceKind:ne,traceMsg:g,followups:et,approvals:Mt,approvalMsg:kt,peerMessages:ft,peerMsg:Ja,threadText:Xe,threadGateNote:Gt,threadMsg:Jl,threadList:ya,chatMsg:Rl,chatHistory:ga,showDetail:Il,refreshDetailLists:du,doAction:Pl,taskCost:Ce,forkTask:sl,postReview:Ae,submitFindings:mu,decideApproval:hu,postPeer:vu,threadAction:pa,sendChatMessage:Sa,downloadTrace:xa,refreshReviews:()=>void Xt(),refreshApprovals:()=>void bl()}}function ug(i){const[r,h]=d.useState([]),[f,y]=d.useState(""),[A,M]=d.useState(null),[p,_]=d.useState(""),[R,Q]=d.useState(null),[b,H]=d.useState(""),Z=d.useCallback(async L=>{const ut=L!==void 0?L:i();if(!ut){h([]);return}try{const O=await F(J.projectAutomations(ut));h(O.automations||[])}catch(O){y("自动化Line truncated
(链路摘要已截断至 300 字,完整回复见右侧任务结果)`:f,label:"助手"}}if(i.kind==="exec"||i.kind==="file"||i.kind==="tool"||i.kind==="call"){const f=i.kind==="exec"?"命令":i.kind==="file"?"文件":i.kind==="tool"?"工具":"调用";return{id:h,role:"tool",kind:i.kind,text:go(i),label:f}}return i.kind==="plan"?{id:h,role:"status",kind:i.kind,text:i.text||""}:i.kind==="reasoning"?{id:h,role:"status",kind:i.kind,text:"思考:"+(i.summary||"")}:i.kind==="agent-start"?{id:h,role:"status",kind:i.kind,text:"子代理开始:"+(i.name||"")+(i.prompt?"("+i.prompt+")":"")}:i.kind==="agent-done"?{id:h,role:"status",kind:i.kind,text:"子代理结束:"+(i.name||"")+" "+(i.status||"")}:{id:h,role:"status",kind:i.kind,text:go(i)}}function pg(){const i=Yt(),r=i.currentTask,h=d.useMemo(()=>{if(!r)return[];let _;if(i.chatHistory){_=i.chatHistory.messages.map(W=>({id:W.id,role:W.role,text:W.text,label:W.label,kind:W.kind}));const H=new Set(_.map(W=>W.id)),Z=i.traceEntries.filter(W=>W.id&&!H.has(W.id)&&!String(W.id).startsWith("fu-")).map((W,ot)=>Lh(W,ot));_=[..._,...Z]}else{const H=r.prompt?[{id:"task-prompt",role:"user",text:r.prompt}]:[],Z=i.traceEntries.filter(ot=>!String(ot.id||"").startsWith("fu-")).map((ot,rt)=>Lh(ot,rt)),W=i.followups.map((ot,rt)=>({id:"followup-"+rt,role:"user",text:String(ot.body||""),label:"后续消息"}));_=[...H,...Z,...W]}const R=i.traceKind,Q=R?_.filter(H=>H.role==="user"||!H.kind||H.kind===R):_,b=i.traceDone?[{id:"trace-done",role:"status",text:"链路已结束("+i.traceDone+")"}]:[];return[...Q,...b]},[r,i.chatHistory,i.traceEntries,i.followups,i.traceKind,i.traceDone]);if(!r)return c.jsx("div",{className:"muted",children:"点任务行查看详情后,这里显示继续会话"});const f=ou.includes(r.status),y=["queued","running"].includes(r.status),A=f||y,M=f?"终态任务:发送走继续接口(同一会话再跑一轮)":y?"运行中/排队中:发送走后续消息直接发送(跑完当前轮后优先执行)":"merged/merge-failed 任务不可继续,也不可发后续消息",p=i.chatHistory?i.chatHistory.legacy_continue_count??null:null;return c.jsxs("div",{className:"chat-column",children:[c.jsx("div",{className:"muted",children:"实时对话"}),c.jsxs(At,{children:[c.jsxs("select",{"aria-label":"链路类型",value:i.traceKind,onChange:_=>i.setTraceKind(_.target.value),children:[c.jsx("option",{value:"",children:"链路类型:全部"}),["exec","file","call","tool","message","reasoning","plan","agent-start","agent-done"].map(_=>c.jsx("option",{value:_,children:_==="exec"?"命令":_==="file"?"文件":_==="call"?"调用":_==="tool"?"工具":_==="message"?"消息":_==="reasoning"?"思考":_==="plan"?"计划":"子代理"},_))]}),c.jsx("button",{onClick:i.downloadTrace,children:"下载完整链路"}),c.jsx("span",{className:"muted",children:i.traceMsg})]}),i.traceNote?c.jsx("div",{children:c.jsx("span",{className:"err",children:i.traceNote})}):null,p!==null&&p>0?c.jsxs("div",{className:"muted",children:["此前有 ",p," 条继续消息发生在持久化之前,未入库,上面历史不完整;部署后新发的继续消息会正常显示。"]}):null,i.chatHistory&&i.chatHistory.trace_note?c.jsx("div",{className:"muted",children:i.chatHistory.trace_note}):null,c.jsx(yg,{messages:h,empty:"(暂无会话消息)"}),c.jsx(gg,{disabled:!A,placeholder:"消息",onSend:_=>void i.sendChatMessage(_)}),c.jsxs("div",{className:"muted",children:[M,"。",i.chatMsg]}),c.jsx("div",{className:"muted",children:"对话时间线:首条是本任务 prompt(用户);助手回复、命令/文件/工具/调用、计划/思考/子代理开始结束都在同一列里,不再另起一份链路名单。流式进行中时新条目实时追加;你在底部时自动跟随,往上翻时暂停跟随,点“回到最新”恢复。"})]})}function Sg(){return Yt().currentTask?c.jsx("div",{className:"conversation",children:c.jsx(pg,{})}):c.jsx("div",{className:"conversation",children:c.jsx("div",{className:"muted",children:"在任务列表里点一条任务,对话和详情就切到该任务;也可以在上面派发一个新任务。"})})}function xg(){const i=Yt(),r=i.currentTask,[h,f]=d.useState("");if(!r)return null;const y=ou.includes(r.status),A=["failed","interrupted","merge-failed"].includes(r.status),M=r.kind==="side";return c.jsxs(c.Fragment,{children:[c.jsxs(At,{children:[c.jsx("button",{disabled:!A,title:A?"":"仅 failed/interrupted/merge-failed 可重试",onClick:()=>i.doAction("retry"),children:"重试"}),c.jsx("button",{className:"danger",disabled:y,title:y?"终态任务不可取消":"",onClick:()=>i.doAction("cancel"),children:"取消"}),M?null:c.jsxs(c.Fragment,{children:[c.jsx("button",{disabled:r.status!=="completed",title:r.status!=="completed"?"仅 completed 可合并":"",onClick:()=>i.doAction("merge"),children:"合并(completed → merged)"}),c.jsx("button",{disabled:r.status!=="merged",title:r.status!=="merged"?"仅 merged 可提 Line truncated
`);for(;nt>=0;){const rt=mt.slice(0,nt);mt=mt.slice(nt+2);for(const G of rt.split(`
`))if(G.startsWith("data: "))try{const A=JSON.parse(G.slice(6));A.waiting?S(A.task_status==="interrupted"?"任务已中断,等待续跑…":A.task_status==="paused"?"任务已暂停…":"等待会话…"):A.image&&(f("data:image/jpeg;base64,"+A.image),S("直播中"));const ut={};A.holder&&(ut.holder=A.holder),A.url&&(ut.url=A.url),A.title!==void 0&&(ut.title=A.title),Object.keys(ut).length>0&&C(bt=>({...bt||{},...ut}))}catch{}nt=mt.indexOf(`
`);for(;ht>=0;){const _t=mt.slice(0,ht);mt=mt.slice(ht+2);for(const G of _t.split(`
`))if(G.startsWith("data: "))try{const Y=JSON.parse(G.slice(6));Y.waiting?p(Y.task_status==="interrupted"?"任务已中断,等待续跑…":Y.task_status==="paused"?"任务已暂停…":"等待会话…"):Y.image&&(f("data:image/jpeg;base64,"+Y.image),p("直播中"));const z={};Y.holder&&(z.holder=Y.holder),Y.url&&(z.url=Y.url),Y.title!==void 0&&(z.title=Y.title),Object.keys(z).length>0&&A(vt=>({...vt||{},...z}))}catch{}ht=mt.indexOf(`
`)}}S("流已断开")}catch(et){if(et.name==="AbortError")return;O(et instanceof Error?et.message:String(et)),S("未开始")}finally{x(!1)}},[r]),J=d.useCallback(()=>{var D;(D=H.current)==null||D.abort(),x(!1),S("已停止")},[]),gt=d.useCallback(async D=>{if(r)try{const X=await W(F.taskBrowserControl(r.id),"POST",{holder:D});C(X),O("")}catch(X){O(X instanceof Error?X.message:String(X))}},[r]),_t=d.useCallback(async D=>{if(!r||(y==null?void 0:y.holder)!=="user"||!L.current)return;const X=L.current.getBoundingClientRect(),et=Math.round((D.clientX-X.left)/X.width*1280),xt=Math.round((D.clientY-X.top)/X.height*800);try{await W(F.taskBrowserInput(r.id),"POST",{type:"mouse_click",x:et,y:xt})}catch(Mt){O(Mt instanceof Error?Mt.message:String(Mt))}},[r,y]),Dt=d.useCallback(async D=>{if(!(!r||(y==null?void 0:y.holder)!=="user")){D.preventDefault();try{D.key.length===1?await W(F.taskBrowserInput(r.id),"POST",{type:"type",text:D.key}):await W(F.taskBrowserInput(r.id),"POST",{type:"key",key:D.key})}catch(X){O(X instanceof Error?X.message:String(X))}}},[r,y]);if(!r)return c.jsx("div",{className:"muted",children:"点任务行查看详情"});const Q=(y==null?void 0:y.holder)||"—",ft=y!==null;return c.jsxs("div",{children:[c.jsxs("div",{style:{display:"flex",gap:8,flexWrap:"wrap",alignItems:"center"},children:[c.jsxs("span",{children:["状态 ",c.jsx("b",{children:M})]}),c.jsxs("span",{className:"muted",children:["会话 ",ft?"活跃":"未激活(轮次结束即消失)"]}),c.jsxs("span",{className:"muted",children:["控制权 ",Q]}),y!=null&&y.url?c.jsx("span",{className:"muted",title:y.url,children:y.url.slice(0,80)}):null]}),c.jsxs("div",{style:{display:"flex",gap:8,flexWrap:"wrap",marginTop:8},children:[Y?c.jsx("button",{type:"button",onClick:J,children:"停止观看"}):c.jsx("button",{type:"button",onClick:()=>void dt(),children:"开始观看"}),c.jsx("button",{type:"button",onClick:()=>void tt(),children:"刷新状态"}),Q!=="user"?c.jsx("button",{type:"button",onClick:()=>void gt("user"),disabled:!ft,children:"接管"}):c.jsx("button",{type:"button",onClick:()=>void gt("agent"),children:"交还给 Agent"})]}),_?c.jsx("div",{className:"muted",style:{marginTop:8},children:_}):null,Q==="user"?c.jsx("div",{className:"muted",style:{marginTop:8},children:"你已接管:点画面即点击,键盘输入直达页面。交还后 Agent 续跑前会先快照确认你的改动。"}):null,c.jsx("div",{style:{marginTop:8},children:h?c.jsx("img",{ref:L,src:h,alt:"浏览器实时画面",tabIndex:0,onClick:D=>void _t(D),onKeyDown:D=>void Dt(D),style:{width:"100%",maxWidth:1280,cursor:Q==="user"?"crosshair":"default",border:"1px solid var(--border, #ccc)"}}):c.jsx("div",{className:"muted",children:ft?"点“开始观看”接入画面流(接管后点画面即点击)":"浏览器会话未激活:任务本轮未开浏览器,或轮次已结束。"})})]})}function jg(){const i=Lt(),[r,h]=d.useState("uncommittedChanges"),[f,y]=d.useState(""),[C,M]=d.useState(""),[S,_]=d.useState("");return c.jsxs(c.Fragment,{children:[c.jsx("h3",{children:"评审队列"}),c.jsxs("select",{"aria-label":"评审目标",value:r,onChange:O=>h(O.target.value),children:[c.jsx("option",{value:"uncommittedChanges",children:"未提交改动(工作区)"}),c.jsx("option",{value:"baseBranch",children:"与分支的差异"}),c.jsx("option",{value:"commit",children:"指定提交"}),c.jsx("option",{value:"custom",children:"自定义评审指令"})]}),c.jsx("input",{className:"stacked",placeholder:"与分支的差异用:分支名(如 main)",style:{width:"100%"},value:f,onChange:O=>y(O.target.value)}),c.jsx("input",{className:"stacked",placeholder:"指定提交用:提交 sha",style:{width:"100%"},value:C,onChange:O=>M(O.target.value)}),c.jsx("textarea",{placeholder:"自定义评审指令用:评审指令正文",value:S,onChange:O=>_(O.target.value)}),c.jsxs(At,{children:[c.jsx("button",{className:"primary",onClick:()=>void i.postReview({target_type:r,branch:f.trim()||void 0,sha:C.trim()||void 0,instructions:S.trim()||void 0}),children:"登记评审需求"}),c.jsx("button",{onClick:i.refreshReviews,children:"刷新队列"}),c.jsx("span",{className:"muted",children:i.reviewMsg})]}),c.jsx("div",{className:"muted",children:"本基线只登记评审需求(requested),不自动跑评审;评审是否执行、结果如何,以评审队列里的记录为准。"}),i.reviewError?c.jsx("div",{className:"muted",children:i.reviewError}):i.reviews.length===0?c.jsx("div",{className:"muted",children:"点任务行查看详情后,点“刷新队列”查看评审记录"}):c.jsx(ou,{children:i.reviews.map(O=>{let Y=O.target_type||"";return O.branch&&(Y+=":"+O.branch),O.sha&&(Y+=":"+String(O.sha).slice(0,12)),O.instructions&&(Y+=":"+String(O.instructions).slice(0,80)),c.jsxs("li",{children:[c.jsxs("span",{className:"row-main",children:[c.jsx("b",{children:"评审"})," ",pe(O.id)," | ",Y," | ",c.jsx("span",{className:"st-"+(O.status||""),children:O.sLine truncated
`)}}p("流已断开")}catch(lt){if(lt.name==="AbortError")return;R(lt instanceof Error?lt.message:String(lt)),p("未开始")}finally{b(!1)}},[r]),rt=d.useCallback(()=>{var O;(O=H.current)==null||O.abort(),b(!1),p("已停止")},[]),bt=d.useCallback(async O=>{if(r)try{const V=await F(J.taskBrowserControl(r.id),"POST",{holder:O});A(V),R("")}catch(V){R(V instanceof Error?V.message:String(V))}},[r]),it=d.useCallback(async O=>{if(!r||(y==null?void 0:y.holder)!=="user"||!Z.current)return;const V=Z.current.getBoundingClientRect(),lt=Math.round((O.clientX-V.left)/V.width*1280),St=Math.round((O.clientY-V.top)/V.height*800);try{await F(J.taskBrowserInput(r.id),"POST",{type:"mouse_click",x:lt,y:St})}catch(Nt){R(Nt instanceof Error?Nt.message:String(Nt))}},[r,y]),wt=d.useCallback(async O=>{if(!(!r||(y==null?void 0:y.holder)!=="user")){O.preventDefault();try{O.key.length===1?await F(J.taskBrowserInput(r.id),"POST",{type:"type",text:O.key}):await F(J.taskBrowserInput(r.id),"POST",{type:"key",key:O.key})}catch(V){R(V instanceof Error?V.message:String(V))}}},[r,y]);if(!r)return c.jsx("div",{className:"muted",children:"点任务行查看详情"});const L=(y==null?void 0:y.holder)||"—",ut=y!==null;return c.jsxs("div",{children:[c.jsxs("div",{style:{display:"flex",gap:8,flexWrap:"wrap",alignItems:"center"},children:[c.jsxs("span",{children:["状态 ",c.jsx("b",{children:M})]}),c.jsxs("span",{className:"muted",children:["会话 ",ut?"活跃":"未激活(轮次结束即消失)"]}),c.jsxs("span",{className:"muted",children:["控制权 ",L]}),y!=null&&y.url?c.jsx("span",{className:"muted",title:y.url,children:y.url.slice(0,80)}):null]}),c.jsxs("div",{style:{display:"flex",gap:8,flexWrap:"wrap",marginTop:8},children:[Q?c.jsx("button",{type:"button",onClick:rt,children:"停止观看"}):c.jsx("button",{type:"button",onClick:()=>void ot(),children:"开始观看"}),c.jsx("button",{type:"button",onClick:()=>void W(),children:"刷新状态"}),L!=="user"?c.jsx("button",{type:"button",onClick:()=>void bt("user"),disabled:!ut,children:"接管"}):c.jsx("button",{type:"button",onClick:()=>void bt("agent"),children:"交还给 Agent"})]}),_?c.jsx("div",{className:"muted",style:{marginTop:8},children:_}):null,L==="user"?c.jsx("div",{className:"muted",style:{marginTop:8},children:"你已接管:点画面即点击,键盘输入直达页面。交还后 Agent 续跑前会先快照确认你的改动。"}):null,c.jsx("div",{style:{marginTop:8},children:h?c.jsx("img",{ref:Z,src:h,alt:"浏览器实时画面",tabIndex:0,onClick:O=>void it(O),onKeyDown:O=>void wt(O),style:{width:"100%",maxWidth:1280,cursor:L==="user"?"crosshair":"default",border:"1px solid var(--border, #ccc)"}}):c.jsx("div",{className:"muted",children:ut?"点“开始观看”接入画面流(接管后点画面即点击)":"浏览器会话未激活:任务本轮未开浏览器,或轮次已结束。"})})]})}function bg(){const i=Yt(),[r,h]=d.useState("uncommittedChanges"),[f,y]=d.useState(""),[A,M]=d.useState(""),[p,_]=d.useState("");return c.jsxs(c.Fragment,{children:[c.jsx("h3",{children:"评审队列"}),c.jsxs("select",{"aria-label":"评审目标",value:r,onChange:R=>h(R.target.value),children:[c.jsx("option",{value:"uncommittedChanges",children:"未提交改动(工作区)"}),c.jsx("option",{value:"baseBranch",children:"与分支的差异"}),c.jsx("option",{value:"commit",children:"指定提交"}),c.jsx("option",{value:"custom",children:"自定义评审指令"})]}),c.jsx("input",{className:"stacked",placeholder:"与分支的差异用:分支名(如 main)",style:{width:"100%"},value:f,onChange:R=>y(R.target.value)}),c.jsx("input",{className:"stacked",placeholder:"指定提交用:提交 sha",style:{width:"100%"},value:A,onChange:R=>M(R.target.value)}),c.jsx("textarea",{placeholder:"自定义评审指令用:评审指令正文",value:p,onChange:R=>_(R.target.value)}),c.jsxs(At,{children:[c.jsx("button",{className:"primary",onClick:()=>void i.postReview({target_type:r,branch:f.trim()||void 0,sha:A.trim()||void 0,instructions:p.trim()||void 0}),children:"登记评审需求"}),c.jsx("button",{onClick:i.refreshReviews,children:"刷新队列"}),c.jsx("span",{className:"muted",children:i.reviewMsg})]}),c.jsx("div",{className:"muted",children:"本基线只登记评审需求(requested),不自动跑评审;评审是否执行、结果如何,以评审队列里的记录为准。"}),i.reviewError?c.jsx("div",{className:"muted",children:i.reviewError}):i.reviews.length===0?c.jsx("div",{className:"muted",children:"点任务行查看详情后,点“刷新队列”查看评审记录"}):c.jsx(Sn,{children:i.reviews.map(R=>{let Q=R.target_type||"";return R.branch&&(Q+=":"+R.branch),R.sha&&(Q+=":"+String(R.sha).slice(0,12)),R.instructions&&(Q+=":"+String(R.instructions).slice(0,80)),c.jsxs("li",{children:[c.jsxs("span",{className:"row-main",children:[c.jsx("b",{children:"评审"})," ",se(R.id)," | ",Q," | ",c.jsx("span",{className:"st-"+(R.status||""),children:R.Line truncated
<style rel="stylesheet" crossorigin>:root{--text:#1f2328;--muted:#656d76;--subtle:#6e7781;--line:#d0d7de;--line-muted:#d8dee4;--paper:#ffffff;--card:#ffffff;--subtle-bg:#f6f8fa;--inset-bg:#f6f8fa;--accent:#0969da;--accent-emphasis:#0969da;--green:#2da44e;--green-hover:#2c974b;--success:#1a7f37;--danger:#d1242f;--danger-bg:#ffebe9;--attention:#9a6700;--done:#8250df;--selected-bg:#ddf4ff;--radius:6px;--shadow:0 1px 0 rgba(27,31,36,.04);--sans:-apple-system,BlinkMacSystemFont,"Segoe UI","Noto Sans",Helvetica,Arial,sans-serif;--mono:ui-monospace,SFMono-Regular,SFMono-Regular,Menlo,Consolas,"Liberation Mono",monospace;--ctl:32px}*{box-sizing:border-box}html,body,#root{height:100%}body{font-family:var(--sans);margin:0;color:var(--text);background:#fff;line-height:1.5;font-size:14px}a{color:var(--accent);text-underline-offset:3px}:focus-visible{outline:2px solid var(--accent);outline-offset:2px}@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important;transition:none!important}}.muted{color:var(--muted);font-size:12px;line-height:1.5}.err{color:var(--danger)}.ok{color:var(--success)}button{cursor:pointer;font-weight:500;border-radius:var(--radius);border:1px solid var(--line);background:#fff;color:var(--text);box-shadow:var(--shadow)}button:hover{background:var(--subtle-bg)}button:disabled{opacity:.45;cursor:not-allowed;box-shadow:none}button.primary,button.seal{background:var(--green);border-color:#1b1f2426;color:#fff}button.primary:hover,button.seal:hover{background:var(--green-hover)}button.danger{color:var(--danger);border-color:var(--line);background:#fff}button.danger:hover{background:var(--danger-bg);color:var(--danger)}.icon-btn{width:32px;height:32px;padding:0;display:grid;place-items:center;border:1px solid var(--line);border-radius:var(--radius);background:#fff;color:var(--text);font-size:16px;line-height:1}.icon-btn:hover{background:var(--subtle-bg)}.dash-topbar{position:sticky;top:0;z-index:10;background:#24292f;color:#fff;border-bottom:1px solid rgba(255,255,255,.15);padding:10px 20px 8px}.dash-topbar .muted{color:#ffffffbf}.dash-topbar-row{display:flex;gap:14px;align-items:center;flex-wrap:wrap}.dash-brand{font-weight:600;font-size:16px;color:#fff}.dash-status{font-size:13px;font-weight:600;color:#ffffffbf}.dash-status.ok{color:#2da44e}.dash-stamp{margin-left:auto}.dash-nav{display:flex;gap:4px;flex-wrap:wrap;margin-top:8px}.dash-nav a{color:#fff;text-decoration:none;font-size:13px;font-weight:600;padding:6px 12px;border-radius:var(--radius)}.dash-nav a:hover{background:#ffffff1f;text-decoration:none}.tab-nav button{background:transparent;border:0;box-shadow:none;color:#fff;font-size:13px;font-weight:600;padding:6px 12px;border-radius:var(--radius)}.tab-nav button:hover{background:#ffffff1f}.tab-nav button.active{background:#ffffff2e}.dash-page-tabs{display:flex}.dash-page-tabs>.tab-content{grid-column:1 / -1;min-width:0;display:flex;flex-direction:column;gap:20px}.tab-workbench{display:grid;grid-template-columns:320px minmax(0,1fr);gap:20px;align-items:start}.tab-workbench>*{min-width:0}@media(max-width:899px){.tab-workbench{grid-template-columns:1fr}.tab-workbench #sec-dispatch{order:1}.tab-workbench #sec-tasks{order:2}.tab-workbench #sec-conv{order:3}}.dash-page{max-width:1180px;margin:0 auto;padding:20px;display:flex;flex-direction:column;gap:20px}.dash-page>*{min-width:0}.dash-section>*{min-width:0}.dash-section{scroll-margin-top:130px;display:flex;flex-direction:column;gap:16px}.dash-section.card{padding:16px}.dash-cards{display:grid;grid-template-columns:repeat(3,1fr);gap:12px}.dash-card{background:#fff;border:1px solid var(--line);border-radius:var(--radius);padding:16px;min-width:0}.dash-card-label{color:var(--muted);font-size:12px;font-weight:600}.dash-card-value{font-size:24px;font-weight:600;line-height:1.3;overflow-wrap:anywhere}.card{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);box-shadow:none;padding:16px}.card h2{font-size:14px;margin:0 0 12px;font-weight:600}.card h3{font-size:14px;margin:24px 0 12px;padding-top:16px;border-top:1px solid var(--line);font-weight:600}.card h3:first-of-type{border-top:0;padding-top:0}.composer h2{font-size:16px}.composer textarea.prompt{min-height:148px;font-size:14px;padding:12px;border-radius:var(--radius)}.composer textarea.coord{font-family:var(--mono);font-size:13px}.bar{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin:12px 0}.bar>button,.bar>label{display:inline-flex;align-items:center;height:var(--ctl);min-height:var(--ctl);padding:0 16px;box-sizing:border-box;white-space:nowrap}.bar>input,.bar>select{height:var(--ctl);min-height:var(--ctl)}input,select,textarea,button{font:inherit;font-size:14px;padding:5px 12px;border-radius:var(--radius);border:1px solid var(--line);background:#fff;color:var(--text)}input:focus,select:focus,textarea:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px #0969da4d}select{-moz-appearance:none;appearance:none;-webkit-appearance:none;cursLine truncated
</head>
<body>
+58
View File
@@ -944,6 +944,7 @@ POSTGRES_SCHEMA = (
"coordinator_parts TEXT, "
"parent_task_id TEXT, "
"coordinator_part TEXT, "
"kind TEXT, "
"goal_resume_pending INTEGER DEFAULT 0, "
"agent_name TEXT, "
"agent_version TEXT, "
@@ -954,6 +955,8 @@ POSTGRES_SCHEMA = (
"audit_loop_packet TEXT, "
"UNIQUE(project_id, idempotency_key))",
"CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status)",
"CREATE INDEX IF NOT EXISTS idx_tasks_parent"
" ON tasks(parent_task_id)",
# 附件包与任务附件:与 server._ensure_uploads_tables 同款,
# POSTGRES_SCHEMA 在每次初始化时全量执行,新表自动补建。
"CREATE TABLE IF NOT EXISTS uploads("
@@ -1162,6 +1165,49 @@ POSTGRES_SCHEMA = (
)
# 启动期 schema 初始化 advisory lock key(Postgres):
# 并发启动(cloud-server 与 worker 同时 init_db)时,CREATE TABLE /
# CREATE INDEX 的 IF NOT EXISTS 检查与建对象不是原子的——两个会话
# 都查到“不存在”后同时建,会撞 pg_type_typname_nsp_index(建表)
# 或 pg_class_relname_nsp_index(建索引)的 UniqueViolation,启动
# 即崩、靠 systemd 重启才自愈(37ba687 与 7983567 两次部署均撞上;
# 7983567 新增的 idx_tasks_parent 是当时的新对象)。
# 持锁串行化整个初始化:init_postgres_schema 内持事务级锁(随本
# 函数末尾的 commit 释放),server.init_db 另持会话级锁覆盖其后
# 的 _ensure_*(task_continue_messages / user_gateway_keys /
# task_attempts / task_audit_loop_state 不在 POSTGRES_SCHEMA 里,同样
# 会撞)。key 须全项目唯一:限流清理另有
# RATE_LIMIT_SWEEP_ADVISORY_KEY = 20261027(cloud/server.py),
# 两处绝不能撞 key。
SCHEMA_INIT_ADVISORY_KEY = 20261108
def acquire_schema_init_lock(conn):
"""取 schema 初始化会话级 advisory 锁(阻塞等,不 try)。
调用方须在初始化全部完成后调 release_schema_init_lock 释放;
连接关闭时也会自动释放。初始化必须做完不能跳过,所以这里用
阻塞取锁,不用 pg_try_advisory_lock。
"""
conn.execute(
"SELECT pg_advisory_lock(?)",
(SCHEMA_INIT_ADVISORY_KEY,))
def release_schema_init_lock(conn):
"""放 schema 初始化会话级 advisory 锁(best-effort)。
事务已 abort 时 SELECT 本身也会失败——吞掉,连接关闭时锁
同样释放,绝不能让放锁失败再挡住启动。
"""
try:
conn.execute(
"SELECT pg_advisory_unlock(?)",
(SCHEMA_INIT_ADVISORY_KEY,))
except Exception:
pass
def init_postgres_schema(conn):
"""在给定适配连接上建出全部 Postgres 表(幂等),并提交。
@@ -1175,6 +1221,15 @@ def init_postgres_schema(conn):
库是 no-op。server.init_db 的 Postgres 分支还会再跑一次
migrate_projects_table,两次补列互不冲突(第二次是 no-op)。
"""
# 事务级 advisory 锁:并发 init_postgres_schema 串行执行,
# 随本函数末尾的 commit 释放;独立调用方(如 migrations)
# 同样受保护。server.init_db 另持会话级锁覆盖 _ensure_*。
# 不取结果行:pg_advisory_xact_lock 返回 void,且假连接
# (test_p2_migrations / test_audit_batch1 的 FakeCursor)
# 没有 fetchone——execute 本身已阻塞到取锁成功。
conn.execute(
"SELECT pg_advisory_xact_lock(?)",
(SCHEMA_INIT_ADVISORY_KEY,))
for statement in POSTGRES_SCHEMA:
conn.execute(statement)
cols = {row[1] for row in conn.execute(
@@ -1376,6 +1431,9 @@ __all__ = [
"PgCursor",
"PgRow",
"POSTGRES_SCHEMA",
"SCHEMA_INIT_ADVISORY_KEY",
"acquire_schema_init_lock",
"release_schema_init_lock",
"init_postgres_schema",
"create_database",
"database_from_env",
+2
View File
@@ -199,6 +199,8 @@ def _task_to_dict(row, attachments=None, total_tokens=None):
if _row_get(row, "coordinator_parts") else None),
"parent_task_id": _row_get(row, "parent_task_id"),
"coordinator_part": _row_get(row, "coordinator_part"),
# 旁聊(Muse 角色对齐 P2):NULL = 老行 = 主聊。
"kind": _row_get(row, "kind") or "main",
"goal_resume_pending": _row_get(row, "goal_resume_pending") or 0,
# agent 定义 v2:身份与指令落库后直接回读(老行缺列
# 时为 None;指令是定义文本、非密钥,任务详情里可见)。
+27 -9
View File
@@ -346,8 +346,15 @@ def extract_task_creates(text):
executor = payload.get("executor")
if executor is not None and executor not in EXECUTORS:
continue
# 旁聊(P2 G7):块里可显式指定 kind("main"|"side"),
# 非法值整块跳过(与 executor 非法同款);不指定时
# worker 按父任务 kind 继承(旁聊父 → 旁聊子)。
kind = payload.get("kind")
if kind is not None and kind not in ("main", "side"):
continue
out.append({"name": name, "prompt": prompt, "model": model,
"effort": effort, "executor": executor})
"effort": effort, "executor": executor,
"kind": kind})
return out
@@ -1011,18 +1018,20 @@ def parent_spawn_generation(conn, parent_id):
只数 parent_task_id 非空且 coordinator_part 为空的祖先链(即
派生链);协调员子任务(coordinator_part 非空)本身不是派生
任务,代数为 0,链到此为止。走 32 步仍未到头时按已数到的算,
绝不死循环。"""
任务,代数为 0,链到此为止;旁聊任务(kind='side')同款——
旁聊不是 marker 派生,链到旁聊为止。走 32 步仍未到头时按已数
到的算,绝不死循环。"""
generation = 0
cur = parent_id
for _ in range(32):
if not cur:
break
row = conn.execute(
"SELECT parent_task_id, coordinator_part FROM tasks"
" WHERE id = ?", (cur,)).fetchone()
"SELECT * FROM tasks WHERE id = ?", (cur,)).fetchone()
if row is None:
break
if row_get(row, "kind") == "side":
break
parent = row_get(row, "parent_task_id")
part = row_get(row, "coordinator_part")
if parent is not None and part is None:
@@ -1034,10 +1043,19 @@ def parent_spawn_generation(conn, parent_id):
def count_direct_spawn_children(conn, parent_id):
"""单个父任务的直接派生子任务数(不含协调员子任务)。"""
row = conn.execute(
"SELECT COUNT(*) FROM tasks WHERE parent_task_id = ?"
" AND coordinator_part IS NULL", (parent_id,)).fetchone()
"""单个父任务的直接派生子任务数(不含协调员子任务与旁聊)。"""
try:
row = conn.execute(
"SELECT COUNT(*) FROM tasks WHERE parent_task_id = ?"
" AND coordinator_part IS NULL"
" AND (kind IS NULL OR kind != 'side')",
(parent_id,)).fetchone()
except Exception:
# 老库/最小测试表缺 kind 列:当时不可能有旁聊行,
# 不带 kind 条件的结果与带条件等价。
row = conn.execute(
"SELECT COUNT(*) FROM tasks WHERE parent_task_id = ?"
" AND coordinator_part IS NULL", (parent_id,)).fetchone()
try:
return int(row[0]) if row is not None else 0
except (TypeError, ValueError, IndexError):
+6 -1
View File
@@ -236,7 +236,12 @@ def migrate_sqlite_to_postgres(sqlite_path, pg_conn=None, tables=None):
# 的 `or 0` 兜底拦不住,audit_loop=0 的
# 历史任务会被悄悄当成启用(Audit batch 1)。
("audit_loop", "INTEGER"),
("audit_loop_packet", "TEXT")):
("audit_loop_packet", "TEXT"),
# 旁聊(Muse 角色对齐 P2):kind 列须与
# db.py POSTGRES_SCHEMA、server
# migrate_tasks_table 同步;NULL = 老行
# = 主聊,无回填。
("kind", "TEXT")):
if _col not in _existing:
try:
pg_conn.execute(
+92 -20
View File
@@ -13,6 +13,7 @@
- CloudExecutor:占位,未接通云端执行。
"""
import hashlib
import json
import logging
import os
@@ -43,6 +44,39 @@ from cloud.peer_messages import extract_peer_messages as _extract_peer_messages
MAX_STDOUT_CHARS = 2000
# 租户 Docker 网络子网划段(2026-11-07 B 方案)。
#
# 背景:宿主 iptables 只放行 172.16.0.0/12 与 127.0.0.1 访问
# Tinyproxy(8889),容器内 codex 必须经该代理到模型网关。
# 以前建网不传 --subnet,子网由 Docker 默认池分配,曾分到
# 192.168.32.0/20(放行范围外),容器零次补全、单轮 600 秒
# 超时两轮后任务失败。划段区选 172.16.0.0/16:它在放行范围
# 内,且 Docker 默认池从 172.17.0.0/16 起分,永远不会自动分
# 到 172.16 段,与现存网络、宿主 LAN、Tailscale 均不重叠
# (2026-11-07 主机实测:10 个网络,172.16.0.0/16 空闲)。
# 每个租户网占一个 /24(254 个地址足够);同一网络名每次
# 算出的候选顺序相同(sha256 首字节起算 + 线性探测),不依
# 赖任何持久化状态;候选子网已被别的租户网占用时顺延下一
# 个。容量上限 256 个租户网/宿主(32 次探测封顶),满了就
# 明确返回失败,不静默降级——扩容需另行决策(删残留旧网或
# 扩大放行范围),不要在这里偷偷换段。
TENANT_SUBNET_PREFIX = "172.16"
TENANT_SUBNET_SLOTS = 256
TENANT_SUBNET_MAX_PROBES = 32
def tenant_subnet_candidates(name):
"""按网络名确定性排出候选租户子网(/24,172.16.0.0/16 内)。
首候选由 sha256(网络名) 首字节决定,其余按槽位顺延;
不同网络名一般落在不同槽,撞槽时靠 ensure_network 的
overlap 探测顺延。纯函数,不碰 Docker、不读状态。
"""
seed = hashlib.sha256(name.encode("utf-8")).digest()[0]
for i in range(TENANT_SUBNET_MAX_PROBES):
slot = (seed + i) % TENANT_SUBNET_SLOTS
yield "%s.%d.0/24" % (TENANT_SUBNET_PREFIX, slot)
def tmp_isolation_status():
"""各层 /tmp 隔离现状的唯一口径(README 与单测都以它为准)。
@@ -558,7 +592,7 @@ class LocalExecutor(Executor):
return re.sub(r"[^A-Za-z0-9_.-]", "-", raw)
def dispatch_task(self, task, seed_dir=None, extra_env=None,
model_provider=None) -> dict:
model_provider=None, no_worktree=False) -> dict:
"""调 host.py dispatch:建 host 任务并跑一轮 turn。
命令:host.py [--config CFG] [--model M] dispatch --name <host 名>
@@ -571,6 +605,8 @@ class LocalExecutor(Executor):
model 换成 fallback 模型,第二次 dispatch/run 就跑在新模型上)。
seed_dir 非空时追加 --seed-dir:host.py 在建 worktree 后、
跑 turn 前把暂存附件拷进去(run 续跑不走这条路,不重复拷)。
no_worktree(旁聊默认):追加 --no-worktree,host.py 不建
worktree/分支,无 repo 时以 per-task scratch 目录为 cwd。
"""
if not isinstance(task, dict) or task.get("id") is None:
raise ValueError("任务缺少 id")
@@ -591,6 +627,8 @@ class LocalExecutor(Executor):
cmd += ["--repo", repo]
if seed_dir:
cmd += ["--seed-dir", seed_dir]
if no_worktree:
cmd += ["--no-worktree"]
return self._result_from_runner(
task["id"], self._run_local(cmd, extra_env))
@@ -639,7 +677,7 @@ class LocalExecutor(Executor):
task["id"], self._run_local(cmd, extra_env))
def goal_task(self, task, seed_dir=None, extra_env=None,
model_provider=None) -> dict:
model_provider=None, no_worktree=False) -> dict:
"""调 host.py goal:目标循环(turn→验测→喂回重试)。
命令:host.py [--config CFG] [--model M] goal --name <host 名>
@@ -686,6 +724,8 @@ class LocalExecutor(Executor):
cmd += ["--repo", repo]
if seed_dir:
cmd += ["--seed-dir", seed_dir]
if no_worktree:
cmd += ["--no-worktree"]
return self._goal_result_from_runner(
task["id"], self._run_local(cmd, extra_env))
@@ -833,7 +873,8 @@ class LocalExecutor(Executor):
}
def popen_task(self, task, kind="dispatch", seed_dir=None,
extra_env=None, model_provider=None):
extra_env=None, model_provider=None,
no_worktree=False):
"""进程组启动 host.py 子进程,返回 Popen(供真取消用)。
start_new_session=True 让子进程自成进程组,worker 取消时
@@ -886,6 +927,8 @@ class LocalExecutor(Executor):
cmd += ["--repo", repo]
if seed_dir:
cmd += ["--seed-dir", seed_dir]
if no_worktree:
cmd += ["--no-worktree"]
elif kind == "goal-resume-breakpoint":
# G10 断点:无 --goal-text,不开新循环;host 侧按
# goal_rounds_done 的下一轮接着跑,轮次上限沿用原
@@ -915,6 +958,8 @@ class LocalExecutor(Executor):
cmd += ["--repo", repo]
if seed_dir:
cmd += ["--seed-dir", seed_dir]
if no_worktree:
cmd += ["--no-worktree"]
if extra_env:
merged = dict(os.environ)
merged.update(extra_env)
@@ -1471,13 +1516,17 @@ class DockerExecutor(Executor):
def dispatch_task(self, task, timeout=None, extra_mounts=None,
network=None, extra_env=None,
seed_host_dir=None, model_provider=None) -> dict:
seed_host_dir=None, model_provider=None,
no_worktree=False) -> dict:
"""容器内调 host.py dispatch:建 host 任务并跑一轮 turn。
seed_host_dir 非空时把暂存目录只读挂载进容器
(/tmp/codex-seed),内层 host.py 用 --seed-dir
/tmp/codex-seed 拷进 worktree;挂载整个目录(不是单个
文件)是为了一包多文件与子目录。"""
文件)是为了一包多文件与子目录。no_worktree(旁聊):
内层命令追加 --no-worktree,容器内 host.py 不建
worktree/分支(worker 只在本地默认旁聊上接线,显式
docker 旁聊走既有语义;此处只做透传)。"""
if not isinstance(task, dict) or task.get("id") is None:
raise ValueError("任务缺少 id")
if not task.get("prompt"):
@@ -1496,6 +1545,8 @@ class DockerExecutor(Executor):
if seed_host_dir:
inner += ["--seed-dir", "/tmp/codex-seed"]
mounts.append("%s:/tmp/codex-seed:ro" % seed_host_dir)
if no_worktree:
inner += ["--no-worktree"]
return self._run_in_container(task, inner, timeout=timeout,
extra_mounts=mounts or None,
network=network,
@@ -1539,11 +1590,13 @@ class DockerExecutor(Executor):
def goal_task(self, task, timeout=None, extra_mounts=None,
network=None, extra_env=None,
seed_host_dir=None, model_provider=None) -> dict:
seed_host_dir=None, model_provider=None,
no_worktree=False) -> dict:
"""容器内调 host.py goal:目标循环(turn→验测→喂回重试)。
seed_host_dir 语义同 dispatch_task(只读挂载 + 内层
--seed-dir /tmp/codex-seed)。
--seed-dir /tmp/codex-seed)。no_worktree 语义同
dispatch_task(内层命令追加 --no-worktree)。
不走 _run_in_container:它经 _parse_host_result 截头
stdout,而 goal 结论在末尾,截头会把结论截掉;这里直接
@@ -1584,6 +1637,8 @@ class DockerExecutor(Executor):
if seed_host_dir:
inner += ["--seed-dir", "/tmp/codex-seed"]
mounts.append("%s:/tmp/codex-seed:ro" % seed_host_dir)
if no_worktree:
inner += ["--no-worktree"]
cmd = self.build_run_command(task, inner,
extra_mounts=mounts or None,
network=network,
@@ -1957,6 +2012,15 @@ class DockerExecutor(Executor):
def ensure_network(self, name):
"""确保 Docker 网络存在:不存在就创建,已存在就忽略。
创建时显式 --subnet 固定在 172.16.0.0/12 内划段(见
tenant_subnet_candidates):宿主 iptables 只放行该范围
访问 egress 代理,不传子网时 Docker 默认池可能分到范
围外(如 192.168.32.0/20),容器连不上代理、任务空等
600 秒超时。候选子网已被占用时按 overlap 报错顺延;
已存在(同名)直接复用——注意:这意味着 2026-11-07
之前建好的范围外旧网(如 codex-smoke-20261107)不会
被自动纠正,须人工删除后重建,见部署清单。
返回 True 表示网络可用(创建成功或本来就存在),
False 表示创建失败(调用方继续派发,docker run 时会
因网络不存在而明确失败,不在这里静默降级默认网络——
@@ -1966,19 +2030,27 @@ class DockerExecutor(Executor):
"""
if not name:
return False
try:
result = self._call_runner(["docker", "network", "create", name],
timeout=30)
except Exception as exc:
return "already exists" in str(exc)
try:
if getattr(result, "returncode", 1) == 0:
return True
text = (getattr(result, "stderr", "") or "") + \
(getattr(result, "stdout", "") or "")
return "already exists" in text
except Exception:
return False
for subnet in tenant_subnet_candidates(name):
try:
result = self._call_runner(
["docker", "network", "create",
"--subnet", subnet, name],
timeout=30)
except Exception as exc:
return "already exists" in str(exc)
try:
if getattr(result, "returncode", 1) == 0:
return True
text = (getattr(result, "stderr", "") or "") + \
(getattr(result, "stdout", "") or "")
if "already exists" in text:
return True
if "overlap" in text.lower():
continue
return False
except Exception:
return False
return False
def execute(self, task) -> dict:
"""只读执行:容器内调 host.py status,不直接跑模型回合。
+229 -33
View File
@@ -54,6 +54,7 @@ from collections import deque
from decimal import Decimal
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, urlparse
import urllib.request
from cloud.agent_def import default_agents_dir as _agent_default_dir
from cloud.agent_def import list_known_skills as _agent_known_skills
@@ -87,7 +88,9 @@ from cloud.timeouts import validate_server_timeout as _validate_timeout
from cloud.auth import audit_on_conn as _audit_on_conn
from cloud.db import connect as _db_connect
from cloud.db import create_database as _create_database
from cloud.db import acquire_schema_init_lock as _acquire_schema_init_lock
from cloud.db import init_postgres_schema as _init_postgres_schema
from cloud.db import release_schema_init_lock as _release_schema_init_lock
from cloud.db import PostgresIntegrityError as _PgIntegrityError
from cloud.db import PostgresOperationalError as _PgOperationalError
from cloud.goal_command import parse_goal_command as _parse_goal_command
@@ -1042,6 +1045,28 @@ def _db_backend_name():
return (os.environ.get("CLOUD_DB_BACKEND") or "sqlite").strip().lower()
def _codex_ui_url():
"""原生 UI host 地址:环境变量 CODEX_UI_URL,未设则默认本机 :8095。"""
return os.environ.get("CODEX_UI_URL") or "http://127.0.0.1:8095/"
def _codex_ui_reachable():
"""探测 UI host 是否可达:GET <CODEX_UI_URL 的 origin>/,2 秒超时。
任一异常(连接失败、超时、DNS 失败等)或非 200 一律视为不可达,
调用方回落旧控制台;探测失败绝不影响 /console 本身的响应。"""
try:
parsed = urlparse(_codex_ui_url())
if not parsed.netloc:
return False
probe = "{}://{}/".format(parsed.scheme or "http", parsed.netloc)
request = urllib.request.Request(probe, method="GET")
with urllib.request.urlopen(request, timeout=2) as resp:
return resp.status == 200
except Exception:
return False
def _deployed_commit():
"""当前上线代码的 commit:deploy.py 部署时在包根写的 DEPLOYED_COMMIT。
@@ -1211,6 +1236,11 @@ def migrate_tasks_table(conn):
"coordinator_parts": "TEXT",
"parent_task_id": "TEXT",
"coordinator_part": "TEXT",
"kind": "TEXT",
# 旁聊(Muse 角色对齐 P2):kind 记任务类型
# ('main' | 'side',NULL = 老行 = 主聊,与 goal_mode
# NULL=0、plan NULL=0 同款惯例);parent_task_id
# 的语义从“协调员/派生父任务”扩为“父会话”。
# G10:goal 断点续跑待处理标记(1 = 已调 /goal-resume、
# 待 worker 按断点续跑;worker 认领后清掉,跑完不再
# 重复触发)。与 resume_message 不同:后者是“给旧会
@@ -1639,22 +1669,32 @@ def init_db(db_path):
conn = _connect(db_path)
try:
if _is_postgres_backend():
_init_postgres_schema(conn)
migrate_tasks_table(conn)
migrate_projects_table(conn)
_ensure_followups_table(conn)
_ensure_continue_messages_table(conn)
_ensure_environments_table(conn)
_ensure_quotas_table(conn)
_backfill_default_quotas(conn)
_ensure_gateway_keys(conn)
_ensure_uploads_tables(conn)
_ensure_task_checkpoints_table(conn)
_ensure_task_steps_table(conn)
_ensure_attempts(conn)
_ensure_audit_loop(conn)
conn.commit()
return
# 会话级 advisory 锁覆盖整个 PG 初始化:_init_postgres_schema
# 内还有事务级锁,但其后的 _ensure_* 建的对象不全在
# POSTGRES_SCHEMA 里(task_continue_messages /
# user_gateway_keys / task_attempts / task_audit_loop_state),
# 只锁前者仍会在后者上撞 UniqueViolation;锁在最终 commit
# 后才放。连接在外层 finally 里关闭,锁至迟随连接释放。
_acquire_schema_init_lock(conn)
try:
_init_postgres_schema(conn)
migrate_tasks_table(conn)
migrate_projects_table(conn)
_ensure_followups_table(conn)
_ensure_continue_messages_table(conn)
_ensure_environments_table(conn)
_ensure_quotas_table(conn)
_backfill_default_quotas(conn)
_ensure_gateway_keys(conn)
_ensure_uploads_tables(conn)
_ensure_task_checkpoints_table(conn)
_ensure_task_steps_table(conn)
_ensure_attempts(conn)
_ensure_audit_loop(conn)
conn.commit()
return
finally:
_release_schema_init_lock(conn)
conn.execute(
"CREATE TABLE IF NOT EXISTS projects("
"id TEXT PRIMARY KEY, "
@@ -1702,6 +1742,9 @@ def init_db(db_path):
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_tasks_status"
" ON tasks(status)")
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_tasks_parent"
" ON tasks(parent_task_id)")
conn.commit()
finally:
conn.close()
@@ -2297,6 +2340,20 @@ def _goal_signal_status(status):
return "pending_mirror_to_host_loop"
def _filter_tasks_by_kind(rows, kind_f):
"""旁聊(Muse 角色对齐 P2):GET /tasks 的 kind 过滤。
kind_f 为空时原样返回(缺省 = 全部,老行为);否则只留
`_task_to_dict` 的 kind 等于 kind_f 的行。抽成独立函数,
不让 `_list_tasks` 再长(长方法清单 pin 在 18 个,见
test_longfunc_inventory)。
"""
if not kind_f:
return rows
return [r for r in rows
if _task_to_dict(r, [], 0)["kind"] == kind_f]
class CloudControlPlaneHandler(OAuthMixin, IdentityMemoryMixin, ProjectMixin, AutomationMixin, FullAutoMixin,
BaseHTTPRequestHandler):
"""控制面请求处理:除 /health 与 /webhooks/* 外一律要求 X-API-Key。"""
@@ -2329,6 +2386,29 @@ class CloudControlPlaneHandler(OAuthMixin, IdentityMemoryMixin, ProjectMixin, Au
self._responded = True
self.wfile.write(body)
def _send_redirect(self, location):
self.send_response(302)
self.send_header("Location", location)
self.send_header("Content-Length", "0")
self.end_headers()
# 发头即算已响应(与 _send_json/_send_html 同款约定)。
self._responded = True
def _serve_console_classic(self):
# 旧 React 控制台(cloud/console.html):只出静态壳,不走鉴权
# 与限流(与 /health 同款 pre-auth 待遇)。壳里没有任何数据;
# 数据一律凭用户在浏览器里填的 X-API-Key 调接口,key 只存在
# 用户浏览器 localStorage。页面文件与 server.py 同目录,改动
# 随部署走。/console(UI host 不可达时回落)与 /console-classic
# 共用本方法,不要复制两份读文件代码。
try:
here = os.path.dirname(os.path.abspath(__file__))
with open(os.path.join(here, "console.html"),
"r", encoding="utf-8") as f:
self._send_html(200, f.read())
except OSError:
self._send_json(404, {"error": "console not found"})
def _read_body(self):
try:
length = int(self.headers.get("Content-Length") or 0)
@@ -2616,18 +2696,16 @@ class CloudControlPlaneHandler(OAuthMixin, IdentityMemoryMixin, ProjectMixin, Au
"commit": _deployed_commit(),
})
return
if path == "/console" and method == "GET":
# 命令台 v1:只出静态壳,不走鉴权与限流(与 /health 同款
# pre-auth 待遇)。壳里没有任何数据;数据一律凭用户在
# 浏览器里填的 X-API-Key 调接口,key 只存在用户浏览器
# localStorage。页面文件与 server.py 同目录,改动随部署走。
try:
here = os.path.dirname(os.path.abspath(__file__))
with open(os.path.join(here, "console.html"),
"r", encoding="utf-8") as f:
self._send_html(200, f.read())
except OSError:
self._send_json(404, {"error": "console not found"})
if path in ("/console", "/console-classic") and method == "GET":
# /console:原生 UI 入口(见 docs/codex-native-ui-alignment.md
# §3):UI host 可达时 302 到 CODEX_UI_URL(默认
# http://127.0.0.1:8095/),不可达时回落旧控制台;
# /console-classic 永远 serve 旧控制台。两者都不走鉴权与
# 限流(与 /health 同款 pre-auth 待遇)。
if path == "/console" and _codex_ui_reachable():
self._send_redirect(_codex_ui_url())
else:
self._serve_console_classic()
return
if path.startswith("/oauth/"):
# OAuth 登录/回调是 pre-auth 端点(用户还没本站 API key,
@@ -3768,6 +3846,69 @@ class CloudControlPlaneHandler(OAuthMixin, IdentityMemoryMixin, ProjectMixin, Au
"error": "协调员任务不跑模型回合,不能与"
" goal/plan/验收命令同用"})
return
# 父任务血缘(Muse 角色对齐 P1):创建请求可携带
# parent_task_id;父任务是协调员且本次新建也是协调员
# 时拒绝(深度恒 ≤1)。父不存在 404、跨项目 400。
# 不带 coordinator_parts 时 parent_task_id 仅作血缘记录
# (第二阶段旁聊的父子关系用同一入参)。
parent_task_id = body.get("parent_task_id")
if parent_task_id is not None:
if not isinstance(parent_task_id, str):
self._send_json(400, {
"error": "parent_task_id must be a string"})
return
parent_row = self._fetch_task(parent_task_id)
if parent_row is None:
self._send_json(404, {"error": "parent task not found"})
return
parent_task = _task_to_dict(parent_row)
if parent_task["project_id"] != project_id:
self._send_json(400, {
"error": "parent task belongs to another project"})
return
if coord_parts and parent_task.get("coordinator_parts"):
self._send_json(400, {
"error": "coordinator task cannot be created"
" under a coordinator task"})
return
# 旁聊(Muse 角色对齐 P2):kind 只认 "main" | "side",
# 缺省 = 主聊;旁聊须带 parent_task_id(已在上段校验
# 存在与同项目),不得带 coordinator_parts(协调员
# 子任务与旁聊是两种父子关系),不得带 upload_id /
# environment_id(附件与环境语义都要求 worktree,
# 旁聊默认没有);父任务不得是协调员任务(协调员
# 本身不跑模型回合,旁聊挂上去没有父会话语义,且
# 会污染协调员的子任务同步快照)。
kind = body.get("kind")
if kind is None:
kind = "main"
if kind not in ("main", "side"):
self._send_json(400, {
"error": "kind must be 'main' or 'side'"})
return
if kind == "side":
if parent_task_id is None:
self._send_json(400, {
"error": "side thread requires parent_task_id"})
return
if coord_parts:
self._send_json(400, {
"error": "side thread cannot carry coordinator_parts"})
return
if parent_task is not None and parent_task.get(
"coordinator_parts"):
self._send_json(400, {
"error": "side thread parent cannot be a"
" coordinator task"})
return
if body.get("upload_id") is not None:
self._send_json(400, {
"error": "side thread cannot carry upload_id"})
return
if body.get("environment_id") is not None:
self._send_json(400, {
"error": "side thread cannot carry environment_id"})
return
idempotency_key = body.get("idempotency_key")
if idempotency_key is not None and not isinstance(idempotency_key, str):
self._send_json(400, {"error": "idempotency_key must be a string"})
@@ -3849,7 +3990,10 @@ class CloudControlPlaneHandler(OAuthMixin, IdentityMemoryMixin, ProjectMixin, Au
# 未指定且无环境继承时默认 docker:空 executor(任意
# worker 可认领)会让任务落到隔离较弱的 local worker,
# 是多用户下的隔离缺口;显式 local/cloud 不受影响。
executor = _default_executor()
# 旁聊例外(P2 G1):旁聊是会话式使用,默认 local、
# 不拉容器;docker/cloud 仅显式要求时才走。
executor = "local" if kind == "side" \
else _default_executor()
# 附件绑定(upload_id 由 POST /uploads 发出):包必须存在、
# 归属当前用户、未使用、24 小时内、暂存目录还在;cloud
# 执行器没有 worktree 可 seed,直接拒绝。校验在 INSERT
@@ -3975,19 +4119,19 @@ class CloudControlPlaneHandler(OAuthMixin, IdentityMemoryMixin, ProjectMixin, Au
" goal, verify_command, goal_max_rounds,"
" model, effort, approval, plan, goal_mode,"
" environment_id, coordinator_parts,"
" parent_task_id, coordinator_part,"
" parent_task_id, coordinator_part, kind,"
" child_model, child_effort,"
" agent_name, agent_version, agent_instructions,"
" agent_tools, agent_tool_approvals, audit_loop)"
" VALUES(?, ?, ?, 'queued', ?, ?, ?, ?, ?, ?, NULL, ?,"
" ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
" ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(task_id, project_id, prompt, idempotency_key,
json.dumps(depends_on), priority, user_id, now, now,
executor, goal, verify_command, goal_max_rounds,
model, effort, approval, plan, goal_mode,
environment_id,
json.dumps(coord_parts) if coord_parts else None,
None, None, child_model, child_effort,
parent_task_id, None, kind, child_model, child_effort,
agent_name_val, agent_version_val,
agent_instructions_val, agent_tools_val,
agent_tool_approvals_val, audit_loop_val),
@@ -4076,6 +4220,13 @@ class CloudControlPlaneHandler(OAuthMixin, IdentityMemoryMixin, ProjectMixin, Au
parent_ids = query.get("parent_task_id")
if parent_ids:
parent_task_id = parent_ids[0]
# 旁聊(Muse 角色对齐 P2):kind=main|side 可选,缺省 =
# 全部(老行为不变);非法值 400。
kind_f = query["kind"][0] if query.get("kind") else None
if kind_f is not None and kind_f not in ("main", "side"):
self._send_json(400, {
"error": "kind must be 'main' or 'side'"})
return
limit = None
offset = 0
if query.get("limit"):
@@ -4112,13 +4263,15 @@ class CloudControlPlaneHandler(OAuthMixin, IdentityMemoryMixin, ProjectMixin, Au
s = r["status"]
status_counts[s] = status_counts.get(s, 0) + 1
if parent_task_id:
filtered = rows
filtered = _filter_tasks_by_kind(rows, kind_f)
else:
filtered = []
for r in rows:
d = _task_to_dict(r, [], 0)
if status_f and d["status"] != status_f:
continue
if kind_f and d["kind"] != kind_f:
continue
if executor_f and (d["executor"] or "") != executor_f:
continue
if q_f and q_f not in ((d["id"] or "") + " " + (d["prompt"] or "")).lower():
@@ -4396,6 +4549,15 @@ class CloudControlPlaneHandler(OAuthMixin, IdentityMemoryMixin, ProjectMixin, Au
return
if self._reject_if_over_quota_for_task(user_id, task):
return
# 协调员任务不接受后续消息(Muse 角色对齐 P1):写入侧
# 会 201 成功、消费侧没有任何读点,宁可入口 409 明拒。
# 改分工请取消后重建协调员;给分工追加指令请直接发给
# 该子任务。
if task.get("coordinator_parts"):
self._send_json(409, {
"error": "coordinator tasks do not accept"
" follow-up messages"})
return
text, mode, body_err = _validate_followup_body(body)
if body_err is not None:
self._send_json(body_err[0], body_err[1])
@@ -5878,6 +6040,12 @@ class CloudControlPlaneHandler(OAuthMixin, IdentityMemoryMixin, ProjectMixin, Au
(http_code, payload),不直接 _send_json;配额检查
由调用方在调本方法前做。"""
task_id = task["id"]
# 协调员任务不接受后续消息(Muse 角色对齐 P1):与
# _create_followup 同款入口 409,见该处注释。
if task.get("coordinator_parts"):
return 409, {
"error": "coordinator tasks do not accept"
" follow-up messages"}
goal_target = _parse_goal_command(text)
# /goal 前置判定见 _classify_goal_followup(纯计算,与
# _create_followup 共用,但只用于 goal_target 非空时:
@@ -6400,6 +6568,14 @@ class CloudControlPlaneHandler(OAuthMixin, IdentityMemoryMixin, ProjectMixin, Au
if err is not None:
self._send_json(err[0], {"error": err[1]})
return
# 旁聊(P2 G5):旁聊没有 worktree,/worktree 按
# “没有本地 worktree”口径回 404,不回 exists=false
# 的摘要(/files 与 /tree 同款分支已在
# _task_worktree_for_files 里回 404)。
if task.get("kind") == "side":
self._send_json(404, {
"error": "no local worktree for this task"})
return
summary = self._worktree_summary(task)
self._send_json(200, dict(summary, task_id=task_id))
@@ -6695,6 +6871,13 @@ class CloudControlPlaneHandler(OAuthMixin, IdentityMemoryMixin, ProjectMixin, Au
return
if self._reject_if_over_quota(user_id):
return
# 旁聊(P2 G6):旁聊无 worktree/分支,分叉 thread
# 历史的变体不在首版,直接 409,不等 worker/host 层
# 以 500/ValueError 收尾。
if task.get("kind") == "side":
self._send_json(409, {
"error": "side thread has no worktree/branch to fork"})
return
message = body.get("message")
if not message or not isinstance(message, str) \
or not message.strip():
@@ -6943,6 +7126,13 @@ class CloudControlPlaneHandler(OAuthMixin, IdentityMemoryMixin, ProjectMixin, Au
user_id, task["project_id"], "approve_merge"):
self._send_json(403, {"error": "forbidden"})
return
# 旁聊(P2 G6):旁聊无 worktree/分支,合并门禁的五项
# 证据(CI/目录/契约/批准/评审)无一有落点,直接 409,
# 不进门禁检查。
if task.get("kind") == "side":
self._send_json(409, {
"error": "side thread has no worktree/branch to merge"})
return
_merge_ok, _merge_reason = _merge_state_decision(
task["status"])
if not _merge_ok:
@@ -8776,6 +8966,12 @@ pid 须为整数、address 须为字符串,否则 400(注册表里的
user_id, task["project_id"], "approve_merge"):
self._send_json(403, {"error": "forbidden"})
return
# 旁聊(P2 G6):旁聊无 worktree/分支,建 PR 无分支可推,
# 直接 409,不等 host 层以 500/ValueError 收尾。
if task.get("kind") == "side":
self._send_json(409, {
"error": "side thread has no worktree/branch for a PR"})
return
if task["status"] != "merged":
self._send_json(409, {
"error": "only merged tasks can create a PR",
+89 -17
View File
@@ -367,16 +367,22 @@ G0 的六问缺一问,G0 就不算过。用户没回复时,协调员等,
角色模板的权威来源是本文件末尾的附录。附录随 SKILL.md 一起播种,
所以租户任务内一定能看到。`templates/roles/` 下的文件是编辑用的源文件,
发布前必须核对附录和源文件一致,不一致时以附录为准并先修源文件。
协调员派生子任务时,必须把对应角色模板全文贴进子任务提示词,
并补上本任务的输入、目录、分支和完成定义。
协调员派生子任务时,主聊人工派生须把对应角色模板全文贴进子任务提示词,
并补上本任务的输入、目录、分支和完成定义;平台协调员任务的分工 prompt
即子任务 prompt 全文,角色模板不由平台注入,如需模板内容,创建人须自己
写进分工 prompt。
只写一句“按 software-engineering skill 做”是不够的,
子任务不一定会读到本技能。
术语:本技能说的“线程”就是平台的“任务(task)”;“协调员任务”就是
`coordinator_parts` 非空的任务;“分工”就是 `coordinator_parts` 的一项;
“子任务”就是 `parent_task_id` 指向协调员的任务;`forked_from` 链与
`parent_task_id` 是两条血缘,不要混用。
## 协调员派生子任务的必填字段
每个子任务提示词必须包含,缺一项就不派生:
1. 角色模板全文(来自本文件附录,租户任务内可直接引用)。
1. 角色模板内容(主聊人工派生时贴模板全文,来自本文件附录,租户任务内可直接引用;平台协调员任务的分工 prompt 即子任务 prompt 全文,角色模板不由平台注入,如需模板内容,创建人须自己写进分工 prompt)。
2. 本阶段输入:PRD 哪个版本、契约哪个版本、设计稿哪个版本。
3. 拥有目录:只能写这一个目录;只读目录:契约、他人目录。
4. 分支名:`feat/swe/<阶段>-<角色>-<简称>`,例如 `feat/swe/client-web`、
@@ -400,6 +406,8 @@ G0 的六问缺一问,G0 就不算过。用户没回复时,协调员等,
| 完成定义 | 产出文件清单 + 必须跑过的自测命令 + 必须提交的证据 |
| TODO-UPDATE 要求 | 结尾报告必须带 TODO-UPDATE 小节,格式见“待办机制” |
校验表里的拥有目录 / 分支名 / 版本 / TODO-UPDATE 是写入分工 prompt 的约定内容,平台只校验分工字段(`coordinator.validate_parts`),不校验、不解析这些项;“缺一项就不派生”在平台侧不成立——平台只校验 `validate_parts` 的字段。
子任务精简包(裁剪规则写死,不是“尽量精简”):只给本端相关的
契约章节、设计页、PRD 故事和冻结决策摘要,绝不把 PRD、契约、
设计全文复制给每个线程。精简包由四部分组成,缺一部分就补齐再发:
@@ -442,13 +450,19 @@ TODO-UPDATE
- 冻结前:架构线程可以自由改契约,改完要通知协调员更新版本号。
- 冻结物(G2 必须齐):API 契约、数据模型、agent 协议、
任务状态机、错误 taxonomy、技术栈表、每端能力矩阵。
(7 项齐全由协调员人工核对;平台只校验契约版本号与冻结标志,
不校验冻结物清单、不发回归任务。)
任务状态机至少包含:创建、排队、运行中、等审批、已完成、
已取消、失败、超时;错误 taxonomy 至少区分:模型失败、工具失败、
用户取消、超时、权限拒绝、配额超限。同一错误在各端不得换一个名字。
- 冻结后:任何线程想改契约,必须给协调员发任务间消息,写清:
改什么、为什么、影响哪些客户端、是否向后兼容。
(收件方是扮演协调员的模型/人工;平台协调员任务本身暂不支持
后续指令,见“任务间问答默认策略与决策记录”一节。)
- 协调员批准后:契约版本号加一(v1 -> v2),架构线程改契约,
受影响的客户端各收到一条变更通知和一个回归任务。
(版本号加一与回归任务派发是人工/协调员操作,平台不执行;
平台只校验版本号与冻结标志。)
- 协调员不批准:提议线程按旧契约做,差异记入已知问题,不悄悄分叉。
## 任务间问答默认策略与决策记录(v0.3 新增)
@@ -458,6 +472,7 @@ TODO-UPDATE
协调员在下一轮答复:确认假设、纠正假设(发回归任务)、或升级为契约变更;
答复本身也要记入决策记录。等答复期间线程不阻塞在原地,先做契约内
无争议的部分。
- 平台协调员任务暂不支持后续指令:给进行中的协调员任务发后续消息会被入口直接拒绝(409),因为平台循环内没有消费点。替代方案见附录 00“任务间问答与决策记录”一节。
- 决策记录落盘路径:`docs/decisions.md`,由协调员维护,子线程只在报告里
提议、不直接改。只追加不改写历史:一条决策写清日期、议题、决定、理由、
影响端;推翻旧决策时追加新条写明“取代 D-n”,绝不改旧条原文、不删旧条。
@@ -531,6 +546,8 @@ tests/ # QA 线程专属,含跨端一致性用例
(只改拥有目录,契约变更经批准除外;检查命令与输出文件清单必须
记入合并记录,见“目录与分支规则”);③ 契约版本是冻结版;
④ 协调员评审通过;⑤ 无未解决评审意见。有一项不过就不合并。
协调员批准与契约冻结版本是每次调合并门禁时提交的证据,平台不持久化;
谎报由审计与 PR/Checks 只读核对事后发现。
- CI 怎么查:用只读方式查,例如本平台的 GET /tasks/{id}/pr/checks。
它是只读诊断,绝不重跑检查、不批准、不合并;还没建 PR 时它会 409
(查无可查),这时先补 PR,不要把它当成 CI 通过。
@@ -538,9 +555,8 @@ tests/ # QA 线程专属,含跨端一致性用例
要求 PR、要求 CI 通过、禁止直接推。目标仓库没开保护时,
协调员要在决策记录里写明风险并去开通,不能假装已保护,
不能靠“大家自觉”代替保护。
- 合并门禁(2026-10-30 补,代码在 cloud/merge_gate.py,服务端
POST /tasks/{id}/merge 已接线;2026-10-31 硬门禁轨加
cloud/merge_gate_hard.py):保护状态只能由调用方或环境变量
- 合并门禁(服务端 POST /tasks/{id}/merge 已接线,门禁实现在
cloud/merge_gate_hard.py,默认 hard):保护状态只能由调用方或环境变量
CLOUD_BRANCH_PROTECTION 显式声明,代码不去 GitHub 核实、不声称
已保护。模式只由环境变量 CLOUD_MERGE_GATE_MODE 控制,默认
hard:上面五项全查,缺一 409 且任务状态不变,并写
@@ -624,8 +640,10 @@ tests/ # QA 线程专属,含跨端一致性用例
- G5 缺陷回路:缺陷回对应客户端或架构线程修,修完回 G5 回归,
回归范围按契约变更影响面定,不是每次都全量重测。
- 成本控制:分阶段本身就是成本控制。G4a 先验证,G4b / G4c 复用已验证物;
协调员、产品、架构用较强模型,重复性强的客户端实现和 QA 可用
较经济的模型,具体以项目实际可用模型和配额为准;
模型与强度按平台字段走:分工不写 model/effort 时,继承协调员任务的
child_model/child_effort 或自身模型;“协调员、产品、架构用较强模型,
重复性强的客户端实现和 QA 可用较经济的模型”只是建议,具体以项目
实际可用模型和配额为准;
配额将满时先停新线程,不先透支再解释。
@@ -750,6 +768,8 @@ tests/ # QA 线程专属,含跨端一致性用例
出一份单独签字的审计结论。谁写、谁测、谁审、谁执行、谁签字
五职分开:写是开发线程,测是 QA 线程,审是审计线程,
执行合并/发布是人或宿主操作,签字是审计签字人。
- 平台的 `audit_loop` 是协调员任务的自动重排循环,不是独立审计线程;
独立审计仍须另开线程,两者阻塞关系均为“与”,不可互换。
- 同一人不兼两职,可核查:审计记录的签字人字段必须与任务执行人
字段、协调员批准人字段、QA 测试人字段都不同,有一项相同,
审计结论本身无效,按审计 fail 处理。代码里这就是
@@ -1263,6 +1283,31 @@ full-auto-executor + installation id。质量门禁全部自动且阻塞合并
建仓分支要诚实(组织仓可经 App token 建,用户个人仓需安装会话里先建空仓,
记为 needs_user_once,不说成已自动建仓)。
## 平台协调员(以平台代码为准,skill 不得另说一套)
- 平台协调员任务(`coordinator_parts` 非空的任务)不跑模型回合:
worker 按分工自动扇出并行子任务、等齐、合流成一份汇总报告写回
父任务 `result_text`,汇总报告就是协调员任务的终局产物;合并决定
仍由你(模型/人工)在评审环节给,但评审发生在子任务终态之后,
报告之前没有“逐个放行”。
- 分工一次建齐、并行认领;分工最多 20 个;子任务 prompt 即分工 prompt
原文,平台不贴角色模板、不贴校验表、不贴精简包。
- 有界委派(建任务时声明,事后不可改):单分工超时 `timeout_seconds`
(1..86400 秒,超时只取消该分工、其余继续等)、失败容忍
`on_failure`(`fail` 默认 / `tolerate`:容忍分工失败不拖垮整体,
重跑时其 `failed` 子任务不重排、`interrupted` 的仍重排)、单项截断
`result_chars`(默认 500、上限 2000)。全局超时 3600 秒:到时取消
挂起子任务、父任务失败。平台不提供分工 token/费用预算、分工轮数
上限、按分工隔离写范围——不要许诺这三类边界。
- 配额预留:分工数超过 1 时,剩余配额须覆盖分工数 × $1(可经环境变量
调),不足建任务即 402;子任务费用记父任务创建人账。
- 取消与孤儿收口由平台自动做:取消/超时终局取消未终态子任务并标
`interrupted`,常驻循环每轮扫尾父已终态、子仍未终态的孤儿;你不需、
也不能手动“取消子任务”。
- 模型与强度:分工不写 `model`/`effort` 时,继承协调员任务的
`child_model`/`child_effort` 或自身模型;“协调员、产品、架构用
较强模型”只是建议,不是平台字段。
## 你必须先做的事
1. 按 `software-engineering` 技能的 G0 问卷问清:目标用户、核心能力、
@@ -1281,12 +1326,17 @@ full-auto-executor + installation id。质量门禁全部自动且阻塞合并
完成定义。之后所有状态以这份待办为准,不以对话为准。
你是待办文件的唯一写作者,子线程只在结尾报告里写 TODO-UPDATE,
你核对证据后才勾选。不要虚构任何待办标记语法,不要依赖模型会自动写标记块。
3. 每个子任务提示词必须包含:角色模板全文(来自 SKILL.md 附录)、
本阶段输入的版本号、拥有目录、只读目录、分支名、完成定义、
禁止事项、TODO-UPDATE 要求。缺一项不派生。
3. 每个子任务提示词必须包含:本阶段输入的版本号、拥有目录、
只读目录、分支名、完成定义、禁止事项、TODO-UPDATE 要求,
以及角色模板内容——主聊人工派生时贴模板全文;平台协调员任务
的分工 prompt 即子任务 prompt 全文,角色模板不由平台注入,
如需模板内容,创建人须自己写进分工 prompt。缺一项不派生。
派生前校验表(必填表,填好的表原样贴进提示词,缺一项就不发任务):
客户端名、拥有目录、分支名、契约版本、PRD 版本、设计版本、
完成定义、TODO-UPDATE 要求,共 8 项。
完成定义、TODO-UPDATE 要求,共 8 项。校验表里的拥有目录 /
分支名 / 版本 / TODO-UPDATE 是写入分工 prompt 的约定内容,
平台只校验分工字段(`coordinator.validate_parts`),不校验、
不解析这些项。
子任务精简包(裁剪规则写死):只给本端相关的契约章节、设计页、
PRD 故事和冻结决策摘要(不超过 10 条),绝不把 PRD、契约、设计全文
复制给每个线程;提示词里必须列出精简包所含章节清单,只写“已精简”不算数;
@@ -1298,7 +1348,8 @@ full-auto-executor + installation id。质量门禁全部自动且阻塞合并
- G2:架构师和 UI/UX 设计 2 个线程并行,中间加一次联合评审:
架构的状态机和设计的审批界面对得上才能冻结;收敛后你判定契约是否冻结,
冻结物必须齐:API 契约、数据模型、agent 协议、任务状态机、错误 taxonomy、
技术栈表、每端能力矩阵。
技术栈表、每端能力矩阵。(7 项齐全由你人工核对;平台只校验契约版本号
与冻结标志,不校验冻结物清单、不发回归任务。)
- G3:共享核心 1 个线程(含 mock 服务和契约测试);后端线程如有,
在 G3 阶段就参与契约测试,不要等到 G4 才对接。
- G3.5:可行性 spike。核心链路真跑通(agent 工具就是任务能创建、能跑出进度、
@@ -1327,6 +1378,9 @@ full-auto-executor + installation id。质量门禁全部自动且阻塞合并
(问题清单、热修复记录、回滚触发是否命中)+ v1.1 交接清单齐全,
两项缺一不进 G8;G8 的完成不是下一版已发布,是下一轮 G0-G6 的输入
已冻结齐全。
(阶段顺序是流程编排;平台协调员任务建出后各分工并行执行,不按 G1–G8
分阶段放行——需要分阶段时,分多次建协调员任务,上一阶段的协调员终态
后再建下一阶段。)
## 你的合并规则(评审与执行拆分)
@@ -1357,6 +1411,11 @@ full-auto-executor + installation id。质量门禁全部自动且阻塞合并
- 决策记录落盘 `docs/decisions.md`,由你维护,只追加不改写历史;
一条决策写清日期、议题、决定、理由、影响端;推翻旧决策追加新条
写明“取代 D-n”,绝不改旧条原文、不删旧条。
- 平台协调员任务暂不支持后续指令:给进行中的协调员任务发后续消息
会被入口直接拒绝(409),因为平台循环内没有消费点,写了也等于没发。
替代方案:改分工请取消协调员任务后重建一个新的;给某个进行中分工
追加指令请直接发给该子任务;已终态的协调员不要走复活,重新建一个
协调员任务,或对相关子任务复活后重跑。
## 跨任务产物传递(协调员)
@@ -1376,7 +1435,12 @@ full-auto-executor + installation id。质量门禁全部自动且阻塞合并
- 有进度的中断任务:断点续跑。
- 从未开始的任务:全新重跑,不直接判死。
- 单个客户端失败:只重开它,不重开全部。
- 单个客户端失败:只重开它,不重开全部——这是流程口径;平台语义
(`cloud/coordinator.py` 的 `evaluate_outcome`)是:任一非容忍分工
未完成,协调员任务即失败;`on_failure=tolerate` 的分工失败不拖垮
整体,重跑时其 `failed` 子任务不重排、`interrupted` 的仍重排;
未知分工名按失败处理;`merged` 视同完成。`on_failure=tolerate`
须在建协调员任务时声明,事后不可改。
## 你的交付物
@@ -1387,6 +1451,12 @@ G8 的交付物是 v1.1 交接清单终版与下一轮 G0-G6 输入冻结清单
---
## 独立审计与平台 audit_loop 的区分
- 平台的 `audit_loop` 是协调员任务的自动重排循环(推回包拆分到分工、
子任务重排),不是独立审计线程;独立审计仍须另开线程(附录 11-auditor),
审计 fail 自动推回并阻塞合并/发布,签字人与执行人、协调员批准人、
QA 测试人全不同。两者阻塞关系均为“与”,不可互换。
## GitHub 保证(协调员)
@@ -1401,13 +1471,15 @@ G8 的交付物是 v1.1 交接清单终版与下一轮 G0-G6 输入冻结清单
测试证据、已知问题、关联待办或 PRD 项;评审意见逐条处理,
未解决的评审意见不能合并。
- 合并门:GitHub Checks 全绿、目录归属检查通过、契约版本是冻结版、
你评审通过、无未解决评审意见,五项缺一不可。
你评审通过、无未解决评审意见,五项缺一不可。协调员批准与契约冻结
版本是每次调合并门禁时提交的证据,平台不持久化;谎报由审计与
PR/Checks 只读核对事后发现。
CI 用只读方式查,例如 GET /tasks/{id}/pr/checks,它绝不重跑检查、
不批准、不合并;无 PR 时它会 409,先补 PR。
- 主分支保护:要求 PR、要求 CI 通过、禁止直接推,这是 GitHub 仓库设置,
不是本平台代码能替你开的。没开保护时记为风险并去开通,不假装已保护。
- 合并门禁:同正文,代码在 cloud/merge_gate.py 与
cloud/merge_gate_hard.py,模式由 CLOUD_MERGE_GATE_MODE 控制,
- 合并门禁:同正文,门禁实现在 cloud/merge_gate_hard.py(默认 hard),
模式由 CLOUD_MERGE_GATE_MODE 控制,
默认 hard:五项全查、缺一 409、状态不变、写 merge_gate_blocked
审计,无证据不放行,unknown 不等于 protected,未保护即使带
显式确认也不放行;record / enforce / off 须显式设置才生效,
@@ -14,6 +14,31 @@ full-auto-executor + installation id。质量门禁全部自动且阻塞合并
建仓分支要诚实(组织仓可经 App token 建,用户个人仓需安装会话里先建空仓,
记为 needs_user_once,不说成已自动建仓)。
## 平台协调员(以平台代码为准,skill 不得另说一套)
- 平台协调员任务(`coordinator_parts` 非空的任务)不跑模型回合:
worker 按分工自动扇出并行子任务、等齐、合流成一份汇总报告写回
父任务 `result_text`,汇总报告就是协调员任务的终局产物;合并决定
仍由你(模型/人工)在评审环节给,但评审发生在子任务终态之后,
报告之前没有“逐个放行”。
- 分工一次建齐、并行认领;分工最多 20 个;子任务 prompt 即分工 prompt
原文,平台不贴角色模板、不贴校验表、不贴精简包。
- 有界委派(建任务时声明,事后不可改):单分工超时 `timeout_seconds`
(1..86400 秒,超时只取消该分工、其余继续等)、失败容忍
`on_failure`(`fail` 默认 / `tolerate`:容忍分工失败不拖垮整体,
重跑时其 `failed` 子任务不重排、`interrupted` 的仍重排)、单项截断
`result_chars`(默认 500、上限 2000)。全局超时 3600 秒:到时取消
挂起子任务、父任务失败。平台不提供分工 token/费用预算、分工轮数
上限、按分工隔离写范围——不要许诺这三类边界。
- 配额预留:分工数超过 1 时,剩余配额须覆盖分工数 × $1(可经环境变量
调),不足建任务即 402;子任务费用记父任务创建人账。
- 取消与孤儿收口由平台自动做:取消/超时终局取消未终态子任务并标
`interrupted`,常驻循环每轮扫尾父已终态、子仍未终态的孤儿;你不需、
也不能手动“取消子任务”。
- 模型与强度:分工不写 `model`/`effort` 时,继承协调员任务的
`child_model`/`child_effort` 或自身模型;“协调员、产品、架构用
较强模型”只是建议,不是平台字段。
## 你必须先做的事
1. 按 `software-engineering` 技能的 G0 问卷问清:目标用户、核心能力、
@@ -32,12 +57,17 @@ full-auto-executor + installation id。质量门禁全部自动且阻塞合并
完成定义。之后所有状态以这份待办为准,不以对话为准。
你是待办文件的唯一写作者,子线程只在结尾报告里写 TODO-UPDATE,
你核对证据后才勾选。不要虚构任何待办标记语法,不要依赖模型会自动写标记块。
3. 每个子任务提示词必须包含:角色模板全文(来自 SKILL.md 附录)、
本阶段输入的版本号、拥有目录、只读目录、分支名、完成定义、
禁止事项、TODO-UPDATE 要求。缺一项不派生。
3. 每个子任务提示词必须包含:本阶段输入的版本号、拥有目录、
只读目录、分支名、完成定义、禁止事项、TODO-UPDATE 要求,
以及角色模板内容——主聊人工派生时贴模板全文;平台协调员任务
的分工 prompt 即子任务 prompt 全文,角色模板不由平台注入,
如需模板内容,创建人须自己写进分工 prompt。缺一项不派生。
派生前校验表(必填表,填好的表原样贴进提示词,缺一项就不发任务):
客户端名、拥有目录、分支名、契约版本、PRD 版本、设计版本、
完成定义、TODO-UPDATE 要求,共 8 项。
完成定义、TODO-UPDATE 要求,共 8 项。校验表里的拥有目录 /
分支名 / 版本 / TODO-UPDATE 是写入分工 prompt 的约定内容,
平台只校验分工字段(`coordinator.validate_parts`),不校验、
不解析这些项。
子任务精简包(裁剪规则写死):只给本端相关的契约章节、设计页、
PRD 故事和冻结决策摘要(不超过 10 条),绝不把 PRD、契约、设计全文
复制给每个线程;提示词里必须列出精简包所含章节清单,只写“已精简”不算数;
@@ -49,7 +79,8 @@ full-auto-executor + installation id。质量门禁全部自动且阻塞合并
- G2:架构师和 UI/UX 设计 2 个线程并行,中间加一次联合评审:
架构的状态机和设计的审批界面对得上才能冻结;收敛后你判定契约是否冻结,
冻结物必须齐:API 契约、数据模型、agent 协议、任务状态机、错误 taxonomy、
技术栈表、每端能力矩阵。
技术栈表、每端能力矩阵。(7 项齐全由你人工核对;平台只校验契约版本号
与冻结标志,不校验冻结物清单、不发回归任务。)
- G3:共享核心 1 个线程(含 mock 服务和契约测试);后端线程如有,
在 G3 阶段就参与契约测试,不要等到 G4 才对接。
- G3.5:可行性 spike。核心链路真跑通(agent 工具就是任务能创建、能跑出进度、
@@ -78,6 +109,9 @@ full-auto-executor + installation id。质量门禁全部自动且阻塞合并
(问题清单、热修复记录、回滚触发是否命中)+ v1.1 交接清单齐全,
两项缺一不进 G8;G8 的完成不是下一版已发布,是下一轮 G0-G6 的输入
已冻结齐全。
(阶段顺序是流程编排;平台协调员任务建出后各分工并行执行,不按 G1–G8
分阶段放行——需要分阶段时,分多次建协调员任务,上一阶段的协调员终态
后再建下一阶段。)
## 你的合并规则(评审与执行拆分)
@@ -108,6 +142,11 @@ full-auto-executor + installation id。质量门禁全部自动且阻塞合并
- 决策记录落盘 `docs/decisions.md`,由你维护,只追加不改写历史;
一条决策写清日期、议题、决定、理由、影响端;推翻旧决策追加新条
写明“取代 D-n”,绝不改旧条原文、不删旧条。
- 平台协调员任务暂不支持后续指令:给进行中的协调员任务发后续消息
会被入口直接拒绝(409),因为平台循环内没有消费点,写了也等于没发。
替代方案:改分工请取消协调员任务后重建一个新的;给某个进行中分工
追加指令请直接发给该子任务;已终态的协调员不要走复活,重新建一个
协调员任务,或对相关子任务复活后重跑。
## 跨任务产物传递(协调员)
@@ -127,7 +166,12 @@ full-auto-executor + installation id。质量门禁全部自动且阻塞合并
- 有进度的中断任务:断点续跑。
- 从未开始的任务:全新重跑,不直接判死。
- 单个客户端失败:只重开它,不重开全部。
- 单个客户端失败:只重开它,不重开全部——这是流程口径;平台语义
(`cloud/coordinator.py` 的 `evaluate_outcome`)是:任一非容忍分工
未完成,协调员任务即失败;`on_failure=tolerate` 的分工失败不拖垮
整体,重跑时其 `failed` 子任务不重排、`interrupted` 的仍重排;
未知分工名按失败处理;`merged` 视同完成。`on_failure=tolerate`
须在建协调员任务时声明,事后不可改。
## 你的交付物
@@ -138,6 +182,12 @@ G8 的交付物是 v1.1 交接清单终版与下一轮 G0-G6 输入冻结清单
---
## 独立审计与平台 audit_loop 的区分
- 平台的 `audit_loop` 是协调员任务的自动重排循环(推回包拆分到分工、
子任务重排),不是独立审计线程;独立审计仍须另开线程(附录 11-auditor),
审计 fail 自动推回并阻塞合并/发布,签字人与执行人、协调员批准人、
QA 测试人全不同。两者阻塞关系均为“与”,不可互换。
## GitHub 保证(协调员)
@@ -152,13 +202,15 @@ G8 的交付物是 v1.1 交接清单终版与下一轮 G0-G6 输入冻结清单
测试证据、已知问题、关联待办或 PRD 项;评审意见逐条处理,
未解决的评审意见不能合并。
- 合并门:GitHub Checks 全绿、目录归属检查通过、契约版本是冻结版、
你评审通过、无未解决评审意见,五项缺一不可。
你评审通过、无未解决评审意见,五项缺一不可。协调员批准与契约冻结
版本是每次调合并门禁时提交的证据,平台不持久化;谎报由审计与
PR/Checks 只读核对事后发现。
CI 用只读方式查,例如 GET /tasks/{id}/pr/checks,它绝不重跑检查、
不批准、不合并;无 PR 时它会 409,先补 PR。
- 主分支保护:要求 PR、要求 CI 通过、禁止直接推,这是 GitHub 仓库设置,
不是本平台代码能替你开的。没开保护时记为风险并去开通,不假装已保护。
- 合并门禁:同正文,代码在 cloud/merge_gate.py 与
cloud/merge_gate_hard.py,模式由 CLOUD_MERGE_GATE_MODE 控制,
- 合并门禁:同正文,门禁实现在 cloud/merge_gate_hard.py(默认 hard),
模式由 CLOUD_MERGE_GATE_MODE 控制,
默认 hard:五项全查、缺一 409、状态不变、写 merge_gate_blocked
审计,无证据不放行,unknown 不等于 protected,未保护即使带
显式确认也不放行;record / enforce / off 须显式设置才生效,
+80
View File
@@ -0,0 +1,80 @@
"""Codex 原生 UI host 入仓清单单测(见 docs/codex-native-ui-alignment.md §4 第 4 项)。
只做静态断言,不起服务、不调网络、不读密钥:
- appserver-methods.json 是合法 JSON、179 个方法、无重复;
- app-server-bridge.mjs 里 7 个仿真方法都在(读文件文本断言方法名出现);
- 仓内无 cloud/codex-ui-host/original/ 目录(650MB 原版栈不入仓);
- scripts/run-codex-ui.sh 存在且可执行。
路径一律以仓库根(本文件向上两级)为基准,不写死绝对路径。
"""
import json
import os
import unittest
REPO_ROOT = os.path.dirname(os.path.dirname(
os.path.dirname(os.path.abspath(__file__))))
# §4 适配 A5:150 个 UI 清单方法 + 29 个实验协议方法 = 179。
EXPECTED_METHOD_COUNT = 179
# §4 适配 A1–A4:桥接层仿真的 7 个缺失方法。
SIMULATED_METHODS = [
"thread/stop",
"thread/rollback",
"thread/startAeon",
"interactive/liveSessions/list",
"interactive/session/upload",
"interactive/sessionSandbox/list",
"interactive/sessionSandbox/read",
]
class TestAppserverMethodsJson(unittest.TestCase):
def test_valid_json_179_methods_no_duplicates(self):
path = os.path.join(
REPO_ROOT, "cloud", "codex-ui-host", "host",
"appserver-methods.json")
with open(path, encoding="utf-8") as f:
methods = json.load(f)
self.assertIsInstance(methods, list)
self.assertEqual(len(methods), EXPECTED_METHOD_COUNT)
self.assertTrue(all(isinstance(m, str) for m in methods))
self.assertEqual(len(set(methods)), len(methods),
"appserver-methods.json must not list a method twice")
class TestAppServerBridgeSimulations(unittest.TestCase):
def test_seven_simulated_methods_present(self):
path = os.path.join(
REPO_ROOT, "cloud", "codex-ui-host", "host",
"app-server-bridge.mjs")
with open(path, encoding="utf-8") as f:
text = f.read()
for name in SIMULATED_METHODS:
self.assertIn(name, text,
"%s must be simulated in app-server-bridge.mjs"
% name)
class TestOriginalNotVendored(unittest.TestCase):
def test_no_original_directory_in_repo(self):
self.assertFalse(
os.path.exists(os.path.join(
REPO_ROOT, "cloud", "codex-ui-host", "original")),
"cloud/codex-ui-host/original/ must not be vendored "
"(650MB original stack stays out of the repo)")
class TestRunScript(unittest.TestCase):
def test_run_script_exists_and_is_executable(self):
path = os.path.join(REPO_ROOT, "scripts", "run-codex-ui.sh")
self.assertTrue(os.path.isfile(path),
"scripts/run-codex-ui.sh must exist")
self.assertTrue(os.access(path, os.X_OK),
"scripts/run-codex-ui.sh must be executable")
if __name__ == "__main__":
unittest.main()
+63 -9
View File
@@ -1,8 +1,14 @@
"""命令台 /console 单测:只验页面壳与路由,不跑浏览器 JS。
页面本身不走鉴权(只是个壳),数据一律凭 X-API-Key 调接口,
所以 /console 无 key 也应 200 出 HTML;真正的鉴权由各 API
端点负责(已有单测覆盖)。
/console 是原生 UI 入口(见 docs/codex-native-ui-alignment.md §3):
UI host 可达时 302 到 CODEX_UI_URL,不可达时回落旧控制台;
/console-classic 永远 serve 旧控制台。两者都不走鉴权(只是个
壳),数据一律凭 X-API-Key 调接口,所以无 key 也应 200 出 HTML;
真正的鉴权由各 API 端点负责(已有单测覆盖)。
默认测试环境没有 UI host 在跑,所以 /console 的断言是回落行为;
302 行为由 test_console_redirects_to_ui_host_when_reachable 用
测试内起的假 UI host 单独覆盖。
"""
import http.client
import os
@@ -10,7 +16,9 @@ import sys
import tempfile
import threading
import unittest
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from unittest import mock
try:
from cloud.auth import AuthStore
@@ -32,6 +40,13 @@ class ConsoleTestCase(unittest.TestCase):
self.thread = threading.Thread(
target=self.server.serve_forever, daemon=True)
self.thread.start()
# 回落断言必须与环境无关:显式把 UI host 指到一个不可能有
# 监听的端口(127.0.0.1:1 立即 connection refused),而不是
# 赌测试机 :8095 上恰好没服务;302 测试在内层再覆盖它。
self._env_patch = mock.patch.dict(
os.environ, {"CODEX_UI_URL": "http://127.0.0.1:1/"})
self._env_patch.start()
self.addCleanup(self._env_patch.stop)
def tearDown(self):
self.server.shutdown()
@@ -56,10 +71,47 @@ class ConsoleTestCase(unittest.TestCase):
finally:
conn.close()
def test_console_serves_html_without_key(self):
def test_console_falls_back_to_classic_when_ui_host_unreachable(self):
# UI host 不可达时 /console 回落旧控制台:无 key 200 出 HTML。
status, headers, body = self._get("/console")
self.assertEqual(status, 200)
self.assertIn("text/html", headers.get("Content-Type", ""))
self.assertIn("Codex 命令台", body.decode("utf-8"))
def test_console_redirects_to_ui_host_when_reachable(self):
# 假 UI host:探测只看 GET / 是否 200,内容不重要。
class _FakeUIHandler(BaseHTTPRequestHandler):
def do_GET(self):
body = b"ok"
self.send_response(200)
self.send_header("Content-Type", "text/plain")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, *args):
pass
fake = ThreadingHTTPServer(("127.0.0.1", 0), _FakeUIHandler)
thread = threading.Thread(
target=fake.serve_forever, daemon=True)
thread.start()
try:
ui_url = "http://127.0.0.1:{}/".format(
fake.server_address[1])
with mock.patch.dict(os.environ, {"CODEX_UI_URL": ui_url}):
status, headers, _body = self._get("/console")
self.assertEqual(status, 302)
self.assertEqual(headers.get("Location"), ui_url)
finally:
fake.shutdown()
fake.server_close()
thread.join(timeout=5)
def test_console_classic_serves_html_without_key(self):
status, headers, body = self._get("/console-classic")
self.assertEqual(status, 200)
self.assertIn("text/html", headers.get("Content-Type", ""))
text = body.decode("utf-8")
self.assertIn("Codex 命令台", text)
# 壳必须引用它实际调的接口,防 JS 与服务端路由漂移。
@@ -95,9 +147,10 @@ class ConsoleTestCase(unittest.TestCase):
def test_console_does_not_leak_data_without_key(self):
# 壳里只有静态文本,没有任何任务/项目数据(数据须凭 key 调 API)。
_status, _headers, body = self._get("/console")
text = body.decode("utf-8")
self.assertNotIn(self.owner_id, text)
for path in ("/console", "/console-classic"):
_status, _headers, body = self._get(path)
text = body.decode("utf-8")
self.assertNotIn(self.owner_id, text)
def test_console_init_loads_relay_models(self):
# 回访用户 key 已在 localStorage 里:初始化序列必须含
@@ -106,8 +159,9 @@ class ConsoleTestCase(unittest.TestCase):
# React 版(2026-10-29 起):旧单页的 setKeyState/loadHealth
# 序列已不存在,初始化在 useConsoleStore 的 useEffect 里,
# 有 key 时调 loadRelayModels;断言意图不变,只换成
# React 构建产物里实际存在的标记。
_status, _headers, body = self._get("/console")
# React 构建产物里实际存在的标记。断言对象是旧控制台壳,
# 所以走 /console-classic(/console 只在回落时才出这个壳)。
_status, _headers, body = self._get("/console-classic")
text = body.decode("utf-8")
self.assertIn("loadRelayModels", text)
self.assertIn("/relay/models", text)
+116
View File
@@ -238,6 +238,25 @@ class WorkerCoordinatorTestCase(unittest.TestCase):
self.assertEqual(backend["effort"], "high")
self.assertEqual(backend["status"], "queued")
def test_sync_children_never_carry_coordinator_parts(self):
# 纵深防御(Muse 角色对齐 P1):_coord_sync_children 建出
# 的子任务行 coordinator_parts 恒为 NULL,子任务永为普通任务。
_insert_task(self.db_path, "coord", coordinator_parts=self._parts())
worker = self._worker()
task = worker.get_task("coord")
worker._coord_sync_children(task, task["coordinator_parts"])
conn = sqlite3.connect(self.db_path)
try:
rows = conn.execute(
"SELECT id, coordinator_parts FROM tasks"
" WHERE parent_task_id = ?",
("coord",)).fetchall()
finally:
conn.close()
self.assertEqual(len(rows), 2)
for task_id, parts in rows:
self.assertIsNone(parts, task_id)
def test_sync_requeues_failed_keeps_completed(self):
_insert_task(self.db_path, "coord", coordinator_parts=self._parts())
_insert_task(self.db_path, "c-old", status="failed",
@@ -469,6 +488,103 @@ class ServerCoordinatorTestCase(unittest.TestCase):
self.assertEqual(status, 200)
self.assertEqual(len(data["tasks"]), 3)
def _create_task(self, extra=None):
body = {"project_id": self.project["id"], "prompt": "做点事"}
if extra:
body.update(extra)
return self._req("POST", "/tasks", body, self.owner_key)
def test_create_child_coordinator_under_plain_parent_ok(self):
status, parent = self._create_task()
self.assertEqual(status, 201)
status, child = self._create_coord(
extra={"parent_task_id": parent["id"]})
self.assertEqual(status, 201)
self.assertEqual(child["parent_task_id"], parent["id"])
self.assertEqual(len(child["coordinator_parts"]), 2)
def test_create_coordinator_under_coordinator_rejected(self):
status, coord = self._create_coord()
self.assertEqual(status, 201)
status, body = self._create_coord(
extra={"parent_task_id": coord["id"]})
self.assertEqual(status, 400)
self.assertIn("coordinator task cannot be created under a"
" coordinator task", body["error"])
status, data = self._req(
"GET", "/tasks?project_id=" + self.project["id"],
key=self.owner_key)
self.assertEqual(status, 200)
self.assertEqual(len(data["tasks"]), 1)
def test_create_parent_task_checks(self):
status, _body = self._create_coord(
extra={"parent_task_id": "no-such-task"})
self.assertEqual(status, 404)
status, other = self._req(
"POST", "/projects", {"name": "other"}, self.owner_key)
self.assertEqual(status, 201)
status, parent = self._create_task(
extra={"project_id": other["id"]})
self.assertEqual(status, 201)
status, body = self._create_coord(
extra={"parent_task_id": parent["id"]})
self.assertEqual(status, 400)
status, body = self._create_coord(
extra={"parent_task_id": 123})
self.assertEqual(status, 400)
def test_create_lineage_without_coordinator_ok(self):
status, coord = self._create_coord()
self.assertEqual(status, 201)
status, child = self._create_task(
extra={"parent_task_id": coord["id"]})
self.assertEqual(status, 201)
self.assertEqual(child["parent_task_id"], coord["id"])
def test_create_idempotent_replay_keeps_single_task(self):
status, parent = self._create_task()
self.assertEqual(status, 201)
extra = {"idempotency_key": "idem-parent",
"parent_task_id": parent["id"]}
status, first = self._create_task(extra=extra)
self.assertEqual(status, 201)
status, second = self._create_task(extra=extra)
self.assertEqual(status, 200)
self.assertEqual(second["id"], first["id"])
status, data = self._req(
"GET", "/tasks?project_id=" + self.project["id"],
key=self.owner_key)
self.assertEqual(len(data["tasks"]), 2)
def test_followup_to_coordinator_rejected(self):
status, coord = self._create_coord()
self.assertEqual(status, 201)
status, body = self._req(
"POST", "/tasks/" + coord["id"] + "/followups",
{"body": "改一下分工", "mode": "queued"}, self.owner_key)
self.assertEqual(status, 409)
self.assertIn("coordinator tasks do not accept follow-up",
body["error"])
status, body = self._req(
"POST", "/tasks/" + coord["id"] + "/thread/message",
{"body": "改一下分工", "mode": "queued"}, self.owner_key)
self.assertEqual(status, 409)
self.assertIn("coordinator tasks do not accept follow-up",
body["error"])
def test_followup_to_plain_task_ok(self):
status, task = self._create_task()
self.assertEqual(status, 201)
status, _body = self._req(
"POST", "/tasks/" + task["id"] + "/followups",
{"body": "继续", "mode": "queued"}, self.owner_key)
self.assertEqual(status, 201)
status, body = self._req(
"POST", "/tasks/" + task["id"] + "/thread/message",
{"body": "继续", "mode": "queued"}, self.owner_key)
self.assertEqual(status, 201)
if __name__ == "__main__":
unittest.main()
@@ -148,7 +148,10 @@ class TestMethodAccounting(unittest.TestCase):
# 增量全是 main 侧方法(_routing_shadow、_resolve_create_
# agent_definition、_validate_create_audit_loop),源线方法
# 零丢失、mixin 侧零增减;此为源线 34b9a34 同款 re-pin。
def test_total_247_no_duplicates(self):
# 补漏注:codex 原生 UI 接线新增 2 个 server.py 自有方法
# (_send_redirect、_serve_console_classic),总数 247 -> 249、
# server.py 自有 153 -> 155;mixin 侧零增减,源线方法零丢失。
def test_total_249_no_duplicates(self):
seen = {}
dups = []
classes = [CloudControlPlaneHandler]
@@ -163,9 +166,9 @@ class TestMethodAccounting(unittest.TestCase):
dups.append((seen[name], cls.__name__, name))
seen[name] = cls.__name__
self.assertEqual(dups, [])
self.assertEqual(total, 247)
self.assertEqual(total, 249)
self.assertEqual(len(_own_methods(CloudControlPlaneHandler)),
247 - 94)
249 - 94)
if __name__ == "__main__":
+2 -1
View File
@@ -185,7 +185,8 @@ class LongtaskAutoResumeTestCase(unittest.TestCase):
captured = {}
def fake_execute(task, kind="dispatch", deadline_seconds=None,
seed_host_dir=None, quota_check=None):
seed_host_dir=None, no_worktree=False,
quota_check=None):
captured["deadline"] = deadline_seconds
return {"status": "completed", "stdout": "", "stderr": ""}
+42
View File
@@ -240,6 +240,48 @@ class TestMigrateNewTables(unittest.TestCase):
"result_text", "error_text", "created_at"):
self.assertIn(col, attempts_inserts[0])
def test_kind_column_backfilled_when_missing(self):
# P2:PG 侧 tasks 尚无 kind 列时,迁移必须先 ALTER 补列
# 再直插,且插入列清单里含 kind;kind 已存在时不重复
# ALTER(与 agent_* 列同款 backfill 惯例)。
with tempfile.TemporaryDirectory() as tmp:
src = os.path.join(tmp, "src.db")
_make_sqlite(src)
fake = FakePgConn(pragma_columns={
"projects": {"repo_url"},
"tasks": {"id", "project_id"},
"task_followups": {"id", "task_id", "project_id",
"body", "mode", "status",
"created_by", "sender_task_id",
"created_at", "updated_at"},
})
result = migrate_sqlite_to_postgres(src, pg_conn=fake)
self.assertEqual(result.get("tasks"), 1, "tasks")
self.assertTrue(
any("tasks" in a and "kind" in a for a in fake.alters),
"未发出 tasks.kind 补列 ALTER:%s" % fake.alters)
# 注:插入列清单取自源 SQLite 表的列,夹具源表无 kind
# 列故清单里无 kind;生产源库(init_db 建表)有 kind,
# 迁移时按列原样搬运(与 agent_* 列同款)。
def test_kind_column_present_skips_alter(self):
with tempfile.TemporaryDirectory() as tmp:
src = os.path.join(tmp, "src.db")
_make_sqlite(src)
fake = FakePgConn(pragma_columns={
"projects": {"repo_url"},
"tasks": {"id", "project_id", "kind"},
"task_followups": {"id", "task_id", "project_id",
"body", "mode", "status",
"created_by", "sender_task_id",
"created_at", "updated_at"},
})
result = migrate_sqlite_to_postgres(src, pg_conn=fake)
self.assertEqual(result.get("tasks"), 1, "tasks")
self.assertFalse(
any("tasks" in a and "kind" in a for a in fake.alters),
"kind 已存在却仍发补列 ALTER:%s" % fake.alters)
def test_sender_column_present_skips_alter(self):
with tempfile.TemporaryDirectory() as tmp:
src = os.path.join(tmp, "src.db")
+75
View File
@@ -28,7 +28,9 @@ import sqlite3
import sys
import tempfile
import time
import traceback
import unittest
from multiprocessing import Barrier, Process, Queue
from pathlib import Path
from types import SimpleNamespace
from unittest import mock
@@ -209,6 +211,18 @@ class SqliteUpsertTestCase(unittest.TestCase):
conn.close()
def _concurrent_init_db_worker(barrier, queue, wid, env):
"""并发启动子进程:与其它进程同时跑 server.init_db。"""
try:
os.environ.update(env)
barrier.wait(timeout=60)
from cloud.server import init_db
init_db(":memory:")
queue.put((wid, "ok", ""))
except Exception:
queue.put((wid, "FAIL", traceback.format_exc()))
class PgInitFixTestCase(unittest.TestCase):
"""真实 PG 测试:无 DSN/驱动时全部 skip。"""
@@ -331,6 +345,67 @@ class PgInitFixTestCase(unittest.TestCase):
finally:
conn.close()
def test_concurrent_init_db_no_unique_violation(self):
"""并发启动回归:4 进程同时 init_db,须全部成功。
覆盖整条启动路径(init_postgres_schema + _ensure_*):
_ensure_* 建的 task_continue_messages / user_gateway_keys /
task_attempts / task_audit_loop_state 不在 POSTGRES_SCHEMA
里,只锁 schema 函数不够;init_db 持会话级 advisory 锁
覆盖全程。空库上跑,未修复时第 1 轮即 3/4 进程报
UniqueViolation(pg_type_typname_nsp_index)。
"""
env = _pg_env()
if env is None:
self.skipTest("未设置可达的 CLOUD_TEST_PG_DSN 或未装驱动,"
"跳过真实 PG 测试")
from cloud.db import PostgresDatabase
db = PostgresDatabase(
host=env["CLOUD_DB_HOST"], port=int(env["CLOUD_DB_PORT"]),
dbname=env["CLOUD_DB_NAME"], user=env["CLOUD_DB_USER"],
password=env["CLOUD_DB_PASSWORD"])
conn = db.connect()
try:
conn.execute("DROP SCHEMA public CASCADE")
conn.execute("CREATE SCHEMA public")
conn.commit()
finally:
conn.close()
nworkers = 4
barrier = Barrier(nworkers)
queue = Queue()
procs = [Process(target=_concurrent_init_db_worker,
args=(barrier, queue, i, env))
for i in range(nworkers)]
try:
for proc in procs:
proc.start()
for proc in procs:
proc.join(timeout=180)
finally:
for proc in procs:
if proc.is_alive():
proc.terminate()
results = [queue.get() for _ in range(nworkers)]
failures = [r for r in results if r[1] == "FAIL"]
self.assertEqual([], failures,
"并发 init_db 有进程失败:%s" % (failures,))
# 初始化完整:schema 表与 _ensure_* 表都在。
conn = db.connect()
try:
rows = db.read_all(
conn,
"SELECT table_name FROM information_schema.tables"
" WHERE table_schema = 'public'",
())
names = {r["table_name"] for r in rows}
for table in ("users", "projects", "tasks",
"task_continue_messages", "user_gateway_keys",
"task_attempts", "task_audit_loop_state"):
self.assertIn(table, names)
finally:
conn.close()
if __name__ == "__main__":
unittest.main()
+80
View File
@@ -17,7 +17,9 @@
import os
import socket
import sys
import traceback
import unittest
from multiprocessing import Barrier, Process, Queue
from pathlib import Path
from unittest import mock
from urllib.parse import unquote, urlparse
@@ -386,3 +388,81 @@ class RealPostgresSchemaTestCase(unittest.TestCase):
except Exception:
pass
conn.close()
def _concurrent_schema_worker(barrier, queue, wid, params):
"""并发初始化子进程:与其它进程同时跑 init_postgres_schema。"""
try:
host, port, dbname, user, password = params
barrier.wait(timeout=60)
db = PostgresDatabase(host=host, port=port, dbname=dbname,
user=user, password=password)
conn = db.connect()
try:
init_postgres_schema(conn)
finally:
conn.close()
queue.put((wid, "ok", ""))
except Exception:
queue.put((wid, "FAIL", traceback.format_exc()))
class RealPostgresConcurrentInitTestCase(unittest.TestCase):
"""真实 PG 并发初始化:无 DSN 或不可达时 skip。
回归:启动竞态(37ba687 与 7983567 两次部署均撞上)——
cloud-server 与 worker 并发 init 时,CREATE TABLE / CREATE
INDEX 的 IF NOT EXISTS 检查与建对象不是原子的,并发建同名
对象会撞 pg_type_typname_nsp_index(建表)或
pg_class_relname_nsp_index(建索引)的 UniqueViolation。
修法是 schema 初始化持 advisory 锁串行化;本用例在空库上
4 进程同时初始化,必须全部成功、表齐全、无一例异常外泄。
"""
def test_concurrent_init_postgres_schema_no_unique_violation(self):
params = _real_pg_params()
if params is None:
self.skipTest("未设置可达的 CLOUD_TEST_PG_DSN,跳过真实 PG 测试")
if not _has_real_driver():
self.skipTest("未装 psycopg 驱动,跳过真实 PG 测试")
host, port, dbname, user, password = params
db = PostgresDatabase(host=host, port=port, dbname=dbname,
user=user, password=password)
conn = db.connect()
try:
conn.execute("DROP SCHEMA public CASCADE")
conn.execute("CREATE SCHEMA public")
conn.commit()
finally:
conn.close()
nworkers = 4
barrier = Barrier(nworkers)
queue = Queue()
procs = [Process(target=_concurrent_schema_worker,
args=(barrier, queue, i, params))
for i in range(nworkers)]
try:
for proc in procs:
proc.start()
for proc in procs:
proc.join(timeout=120)
finally:
for proc in procs:
if proc.is_alive():
proc.terminate()
results = [queue.get() for _ in range(nworkers)]
failures = [r for r in results if r[1] == "FAIL"]
self.assertEqual([], failures,
"并发初始化有进程失败:%s" % (failures,))
conn = db.connect()
try:
rows = db.read_all(
conn,
"SELECT table_name FROM information_schema.tables"
" WHERE table_schema = 'public'",
())
names = {r["table_name"] for r in rows}
for table in SchemaStatementTestCase.EXPECTED_TABLES:
self.assertIn(table, names)
finally:
conn.close()
+730
View File
@@ -0,0 +1,730 @@
"""旁聊任务类型(Muse 角色对齐 P2)单测:创建/列表/执行分流/回写/恢复。
不跑真实模型回合、不联网:服务端侧本地临时端口 + 临时 SQLite;
worker/sandbox 侧注入假 runner 录命令;host.py dispatch 用假
server + 临时 HERE。
"""
import http.client
import json
import os
import sqlite3
import sys
import tempfile
import threading
import time
import unittest
from pathlib import Path
from types import SimpleNamespace
from unittest import mock
try:
import host as host_mod
from cloud.auth import AuthStore
from cloud.marker_tools import (
count_direct_spawn_children,
extract_task_creates,
parent_spawn_generation,
)
from cloud.sandbox import DockerExecutor, LocalExecutor
from cloud.server import create_server, init_db, migrate_tasks_table
from cloud.worker import Worker
except ImportError: # discover -s cloud/tests 时把项目根目录补进 sys.path
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
import host as host_mod
from cloud.auth import AuthStore
from cloud.marker_tools import (
count_direct_spawn_children,
extract_task_creates,
parent_spawn_generation,
)
from cloud.sandbox import DockerExecutor, LocalExecutor
from cloud.server import create_server, init_db, migrate_tasks_table
from cloud.worker import Worker
def _recording(calls):
def fake(cmd):
calls.append(list(cmd))
return SimpleNamespace(returncode=0, stdout="ok", stderr="")
return fake
def _insert_task(db_path, task_id, **cols):
base = {"project_id": "p1", "prompt": "do work", "status": "queued",
"created_by": "tester"}
base.update(cols)
keys = ["id"] + list(base.keys())
vals = [task_id] + list(base.values())
conn = sqlite3.connect(db_path)
try:
conn.execute(
"INSERT INTO tasks(%s) VALUES(%s)"
% (", ".join(keys), ", ".join("?" * len(keys))), vals)
conn.commit()
finally:
conn.close()
def _task_row(db_path, task_id, columns="id, status, kind, executor,"
" parent_task_id, coordinator_part"):
conn = sqlite3.connect(db_path)
try:
row = conn.execute(
"SELECT %s FROM tasks WHERE id = ?" % columns,
(task_id,)).fetchone()
finally:
conn.close()
return row
def _followups(db_path, task_id):
conn = sqlite3.connect(db_path)
try:
rows = conn.execute(
"SELECT id, body, mode, status, sender_task_id"
" FROM task_followups WHERE task_id = ?"
" ORDER BY created_at, id", (task_id,)).fetchall()
finally:
conn.close()
return [{"id": r[0], "body": r[1], "mode": r[2], "status": r[3],
"sender_task_id": r[4]} for r in rows]
def _set_status(db_path, task_id, status):
conn = sqlite3.connect(db_path)
try:
conn.execute("UPDATE tasks SET status = ? WHERE id = ?",
(status, task_id))
conn.commit()
finally:
conn.close()
class _ServerBase(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.db_path = os.path.join(self.tmp.name, "control.db")
self.auth = AuthStore(self.db_path)
self.owner_id, self.owner_key = self.auth.create_user("owner")
self.server = create_server(self.db_path, "127.0.0.1", 0)
self.port = self.server.server_address[1]
self.thread = threading.Thread(
target=self.server.serve_forever, daemon=True)
self.thread.start()
status, self.project = self._req(
"POST", "/projects", {"name": "demo"}, self.owner_key)
self.assertEqual(status, 201)
def tearDown(self):
self.server.shutdown()
self.server.server_close()
self.thread.join(timeout=5)
try:
self.auth.conn.close()
except Exception:
pass
self.tmp.cleanup()
def _req(self, method, path, body=None, key=None):
conn = http.client.HTTPConnection(
"127.0.0.1", self.port, timeout=10)
hdrs = {}
payload = None
if body is not None:
payload = json.dumps(body).encode("utf-8")
hdrs["Content-Type"] = "application/json"
if key is not None:
hdrs["X-API-Key"] = key
try:
conn.request(method, path, body=payload, headers=hdrs)
resp = conn.getresponse()
data = resp.read()
finally:
conn.close()
try:
parsed = json.loads(data.decode("utf-8")) if data else None
except ValueError:
parsed = None
return resp.status, parsed
def _create_task(self, extra=None):
body = {"project_id": self.project["id"], "prompt": "做点事"}
if extra:
body.update(extra)
return self._req("POST", "/tasks", body, self.owner_key)
def _create_side(self, parent_id, extra=None):
body = {"project_id": self.project["id"], "prompt": "旁聊一下",
"kind": "side", "parent_task_id": parent_id}
if extra:
body.update(extra)
return self._req("POST", "/tasks", body, self.owner_key)
class ServerSideThreadTestCase(_ServerBase):
def test_create_side_ok_defaults_local(self):
status, parent = self._create_task()
self.assertEqual(status, 201)
status, side = self._create_side(parent["id"])
self.assertEqual(status, 201, side)
self.assertEqual(side["kind"], "side")
self.assertEqual(side["parent_task_id"], parent["id"])
self.assertEqual(side["executor"], "local")
status, fetched = self._req(
"GET", "/tasks/" + side["id"], key=self.owner_key)
self.assertEqual(status, 200)
self.assertEqual(fetched["kind"], "side")
def test_create_side_validation(self):
status, parent = self._create_task()
self.assertEqual(status, 201)
# 缺 parent_task_id
status, body = self._req(
"POST", "/tasks",
{"project_id": self.project["id"], "prompt": "x",
"kind": "side"}, self.owner_key)
self.assertEqual(status, 400)
# 父不存在
status, _body = self._create_side("no-such-parent")
self.assertEqual(status, 404)
# kind 非法值
status, _body = self._create_task(
{"kind": "weird", "parent_task_id": parent["id"]})
self.assertEqual(status, 400)
# 带 coordinator_parts
status, _body = self._create_side(parent["id"], {
"coordinator_parts": [{"name": "a", "prompt": "x"}]})
self.assertEqual(status, 400)
# 带 upload_id / environment_id
status, _body = self._create_side(
parent["id"], {"upload_id": "up-nonexistent"})
self.assertEqual(status, 400)
status, _body = self._create_side(
parent["id"], {"environment_id": "env-nonexistent"})
self.assertEqual(status, 400)
def test_create_side_cross_project_rejected(self):
status, parent = self._create_task()
self.assertEqual(status, 201)
status, other_project = self._req(
"POST", "/projects", {"name": "other"}, self.owner_key)
self.assertEqual(status, 201)
status, other_task = self._req(
"POST", "/tasks",
{"project_id": other_project["id"], "prompt": "别处的事"},
self.owner_key)
self.assertEqual(status, 201)
status, _body = self._create_side(other_task["id"])
self.assertEqual(status, 400)
def test_create_side_under_coordinator_rejected(self):
status, coord = self._req(
"POST", "/tasks",
{"project_id": self.project["id"], "prompt": "协调干活",
"coordinator_parts": [{"name": "a", "prompt": "做 a"}]},
self.owner_key)
self.assertEqual(status, 201)
status, body = self._create_side(coord["id"])
self.assertEqual(status, 400)
self.assertIn("coordinator", body["error"])
def test_create_side_explicit_docker_kept(self):
status, parent = self._create_task()
self.assertEqual(status, 201)
status, side = self._create_side(
parent["id"], {"executor": "docker"})
self.assertEqual(status, 201, side)
self.assertEqual(side["executor"], "docker")
self.assertEqual(side["kind"], "side")
def test_create_main_defaults(self):
status, task = self._create_task()
self.assertEqual(status, 201)
self.assertEqual(task["kind"], "main")
status, task2 = self._create_task({"kind": "main"})
self.assertEqual(status, 201)
self.assertEqual(task2["kind"], "main")
def test_list_kind_filter(self):
status, parent = self._create_task()
self.assertEqual(status, 201)
_s, side1 = self._create_side(parent["id"])
_s, side2 = self._create_side(parent["id"])
_s, main_child = self._create_task(
{"parent_task_id": parent["id"]})
self.assertEqual(main_child["kind"], "main")
pid = self.project["id"]
status, data = self._req(
"GET", "/tasks?project_id=" + pid +
"&parent_task_id=" + parent["id"] + "&kind=side",
key=self.owner_key)
self.assertEqual(status, 200)
self.assertEqual(
sorted(t["id"] for t in data["tasks"]),
sorted([side1["id"], side2["id"]]))
status, data = self._req(
"GET", "/tasks?project_id=" + pid +
"&parent_task_id=" + parent["id"], key=self.owner_key)
self.assertEqual(status, 200)
self.assertEqual(len(data["tasks"]), 3)
status, data = self._req(
"GET", "/tasks?project_id=" + pid + "&kind=side",
key=self.owner_key)
self.assertEqual(status, 200)
self.assertEqual(
sorted(t["id"] for t in data["tasks"]),
sorted([side1["id"], side2["id"]]))
status, data = self._req(
"GET", "/tasks?project_id=" + pid + "&kind=main",
key=self.owner_key)
self.assertEqual(status, 200)
self.assertEqual(
sorted(t["id"] for t in data["tasks"]),
sorted([parent["id"], main_child["id"]]))
status, _body = self._req(
"GET", "/tasks?project_id=" + pid + "&kind=bogus",
key=self.owner_key)
self.assertEqual(status, 400)
def test_side_files_tree_worktree_404(self):
status, parent = self._create_task()
self.assertEqual(status, 201)
status, side = self._create_side(parent["id"])
self.assertEqual(status, 201)
for suffix in ("/files", "/tree", "/worktree"):
status, body = self._req(
"GET", "/tasks/" + side["id"] + suffix,
key=self.owner_key)
self.assertEqual(status, 404, suffix)
self.assertIn("no local worktree", body["error"])
def test_side_fork_merge_pr_409(self):
status, parent = self._create_task()
self.assertEqual(status, 201)
status, side = self._create_side(parent["id"])
self.assertEqual(status, 201)
status, body = self._req(
"POST", "/tasks/" + side["id"] + "/fork",
{"message": "分一个"}, self.owner_key)
self.assertEqual(status, 409)
self.assertIn("side thread", body["error"])
status, body = self._req(
"POST", "/tasks/" + side["id"] + "/merge", {},
self.owner_key)
self.assertEqual(status, 409)
self.assertIn("side thread", body["error"])
status, body = self._req(
"POST", "/tasks/" + side["id"] + "/pr", {},
self.owner_key)
self.assertEqual(status, 409)
self.assertIn("side thread", body["error"])
def test_side_continue_and_followups(self):
status, parent = self._create_task()
self.assertEqual(status, 201)
status, side = self._create_side(parent["id"])
self.assertEqual(status, 201)
_set_status(self.db_path, side["id"], "completed")
status, task = self._req(
"POST", "/tasks/" + side["id"] + "/continue",
{"message": "再聊聊"}, self.owner_key)
self.assertEqual(status, 200, task)
self.assertEqual(task["status"], "queued")
self.assertEqual(task["resume_message"], "再聊聊")
status, _body = self._req(
"POST", "/tasks/" + side["id"] + "/followups",
{"body": "继续", "mode": "queued"}, self.owner_key)
self.assertEqual(status, 201)
def test_side_writeback_visible_on_terminal_parent(self):
# 父终态时回写只作记录:followups 可见,父不复活。
status, parent = self._create_task()
self.assertEqual(status, 201)
status, side = self._create_side(parent["id"])
self.assertEqual(status, 201)
_set_status(self.db_path, parent["id"], "completed")
conn = sqlite3.connect(self.db_path)
try:
conn.execute(
"INSERT INTO task_followups(id, task_id, project_id,"
" body, mode, status, created_by, sender_task_id,"
" created_at, updated_at)"
" VALUES(?, ?, ?, ?, 'queued', 'pending', ?, ?, ?, ?)",
("fu-1", parent["id"], self.project["id"],
"[旁聊结论] 查到了", "tester", side["id"],
time.time(), time.time()))
conn.commit()
finally:
conn.close()
status, data = self._req(
"GET", "/tasks/" + parent["id"] + "/followups",
key=self.owner_key)
self.assertEqual(status, 200)
self.assertEqual(len(data["followups"]), 1)
self.assertEqual(data["followups"][0]["sender_task_id"],
side["id"])
status, fetched = self._req(
"GET", "/tasks/" + parent["id"], key=self.owner_key)
self.assertEqual(status, 200)
self.assertEqual(fetched["status"], "completed")
class _WorkerBase(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.db_path = os.path.join(self.tmp.name, "control.db")
init_db(self.db_path)
self.env = {"XCM_API_KEY": "test-placeholder"}
def tearDown(self):
self.tmp.cleanup()
def _worker(self, calls):
return Worker(self.db_path, "/fake/host.py",
runner=_recording(calls), env=self.env)
class WorkerSideThreadTestCase(_WorkerBase):
def test_run_one_side_no_repo_no_worktree_and_writeback(self):
_insert_task(self.db_path, "parent1", executor="local")
_insert_task(self.db_path, "side1", kind="side",
parent_task_id="parent1", executor="local")
calls = []
worker = self._worker(calls)
result = worker.run_one(worker.get_task("side1"))
self.assertEqual(result["status"], "completed")
dispatch_cmds = [c for c in calls if "dispatch" in c]
self.assertEqual(len(dispatch_cmds), 1)
cmd = dispatch_cmds[0]
self.assertIn("--no-worktree", cmd)
self.assertNotIn("--repo", cmd)
self.assertNotIn("--seed-dir", cmd)
row = _task_row(self.db_path, "side1")
self.assertEqual(row[1], "completed")
fus = _followups(self.db_path, "parent1")
self.assertEqual(len(fus), 1)
self.assertEqual(fus[0]["sender_task_id"], "side1")
self.assertEqual(fus[0]["mode"], "queued")
self.assertEqual(fus[0]["status"], "pending")
self.assertTrue(fus[0]["body"].startswith("[旁聊结论]"))
def test_run_attempt_side_with_repo_forces_plan(self):
# worker 层直接调 _run_attempt:有 repo 的旁聊强制 plan=1,
# argv 同时含 --no-worktree 与 --sandbox read-only。
calls = []
worker = self._worker(calls)
task = {"id": "side-repo", "prompt": "聊聊 repo",
"project_id": "p1", "created_by": "tester",
"kind": "side", "executor": "local",
"repo": "/tmp/fake-repo",
"_gateway_provider": None, "_gateway_env": {}}
result, error = worker._run_attempt(task)
self.assertIsNone(error)
self.assertEqual(result["status"], "completed")
dispatch_cmds = [c for c in calls if "dispatch" in c]
self.assertEqual(len(dispatch_cmds), 1)
cmd = dispatch_cmds[0]
self.assertIn("--no-worktree", cmd)
self.assertIn("--sandbox", cmd)
self.assertEqual(cmd[cmd.index("--sandbox") + 1], "read-only")
self.assertIn("--repo", cmd)
self.assertEqual(cmd[cmd.index("--repo") + 1], "/tmp/fake-repo")
def test_run_attempt_main_task_untouched(self):
calls = []
worker = self._worker(calls)
task = {"id": "main1", "prompt": "做事",
"project_id": "p1", "created_by": "tester",
"executor": "local",
"_gateway_provider": None, "_gateway_env": {}}
result, error = worker._run_attempt(task)
self.assertIsNone(error)
self.assertEqual(result["status"], "completed")
dispatch_cmds = [c for c in calls if "dispatch" in c]
self.assertEqual(len(dispatch_cmds), 1)
self.assertNotIn("--no-worktree", dispatch_cmds[0])
self.assertNotIn("--sandbox", dispatch_cmds[0])
def test_writeback_direct_shapes(self):
_insert_task(self.db_path, "parent1", executor="local")
worker = Worker(self.db_path, "/fake/host.py", env=self.env)
task = {"id": "side1", "project_id": "p1",
"created_by": "tester", "kind": "side",
"parent_task_id": "parent1"}
self.assertTrue(worker._writeback_side_thread(
task, "completed", "结论文本"))
self.assertTrue(worker._writeback_side_thread(
task, "failed", "出错了"))
self.assertTrue(worker._writeback_side_thread(
task, "interrupted", ""))
fus = _followups(self.db_path, "parent1")
self.assertEqual(len(fus), 3)
self.assertTrue(fus[0]["body"].startswith("[旁聊结论]"))
self.assertTrue(fus[1]["body"].startswith("[旁聊失败]"))
self.assertTrue(fus[2]["body"].startswith("[旁聊中断]"))
# 超长正文截断到 2000 字 + 截断标记。
self.assertTrue(worker._writeback_side_thread(
task, "completed", "x" * 3000))
fus = _followups(self.db_path, "parent1")
self.assertLessEqual(len(fus[3]["body"]), 2010)
self.assertIn("已截断", fus[3]["body"])
# 无父任务不写。
orphan = dict(task, parent_task_id=None)
self.assertFalse(worker._writeback_side_thread(
orphan, "completed", "x"))
def test_parent_running_drain_consumes_writeback(self):
_insert_task(self.db_path, "parent1", executor="local")
_insert_task(self.db_path, "side1", kind="side",
parent_task_id="parent1", executor="local")
worker = Worker(self.db_path, "/fake/host.py", env=self.env)
self.assertTrue(worker._writeback_side_thread(
{"id": "side1", "project_id": "p1", "created_by": "tester",
"kind": "side", "parent_task_id": "parent1"},
"completed", "结论文本"))
calls = []
worker = self._worker(calls)
result = worker.run_one(worker.get_task("parent1"))
self.assertEqual(result["status"], "completed")
# 首轮 + 排空一轮 = 两次 dispatch。
dispatch_cmds = [c for c in calls if "dispatch" in c]
self.assertEqual(len(dispatch_cmds), 2)
fus = _followups(self.db_path, "parent1")
self.assertEqual(len(fus), 1)
self.assertEqual(fus[0]["status"], "done")
class MarkerSideThreadTestCase(_WorkerBase):
def _deliver(self, parent_id, creates):
worker = Worker(self.db_path, "/fake/host.py", env=self.env)
worker._deliver_marker_tools(worker.get_task(parent_id), {
"status": "completed", "task_creates": creates,
"task_polls": [], "wait_threads": [], "task_cancels": [],
"task_retries": []})
def test_extract_kind(self):
text = (
"```task-create\n"
"{\"prompt\": \"做子事\", \"kind\": \"side\"}\n```\n"
"```task-create\n"
"{\"prompt\": \"做子事\", \"kind\": \"weird\"}\n```\n"
"```task-create\n{\"prompt\": \"做子事\"}\n```\n")
creates = extract_task_creates(text)
self.assertEqual(len(creates), 2)
self.assertEqual(creates[0]["kind"], "side")
self.assertIsNone(creates[1]["kind"])
def test_marker_child_inherits_side(self):
_insert_task(self.db_path, "side-parent", kind="side",
executor="local")
self._deliver("side-parent", [{
"name": "子旁聊", "prompt": "继续聊",
"model": None, "effort": None, "executor": None,
"kind": None}])
conn = sqlite3.connect(self.db_path)
try:
child = conn.execute(
"SELECT kind, executor FROM tasks"
" WHERE parent_task_id = ?",
("side-parent",)).fetchone()
finally:
conn.close()
self.assertIsNotNone(child)
self.assertEqual(child[0], "side")
self.assertEqual(child[1], "local")
def test_marker_child_explicit_kind_wins(self):
_insert_task(self.db_path, "side-parent", kind="side",
executor="local")
self._deliver("side-parent", [{
"name": "转主聊", "prompt": "正经做",
"model": None, "effort": None, "executor": None,
"kind": "main"}])
conn = sqlite3.connect(self.db_path)
try:
child = conn.execute(
"SELECT kind FROM tasks WHERE parent_task_id = ?",
("side-parent",)).fetchone()
finally:
conn.close()
self.assertIsNotNone(child)
self.assertEqual(child[0], "main")
def test_marker_counts_exclude_side(self):
_insert_task(self.db_path, "main-parent", executor="local")
_insert_task(self.db_path, "side-kid", kind="side",
parent_task_id="main-parent", executor="local")
_insert_task(self.db_path, "main-kid",
parent_task_id="main-parent", executor="local")
conn = sqlite3.connect(self.db_path)
conn.row_factory = sqlite3.Row
try:
self.assertEqual(
count_direct_spawn_children(conn, "main-parent"), 1)
self.assertEqual(
parent_spawn_generation(conn, "side-kid"), 0)
self.assertEqual(
parent_spawn_generation(conn, "main-kid"), 1)
finally:
conn.close()
class MigrateSideThreadTestCase(unittest.TestCase):
def test_migrate_adds_kind_idempotent(self):
with tempfile.TemporaryDirectory() as tmp:
db_path = os.path.join(tmp, "old.db")
conn = sqlite3.connect(db_path)
try:
conn.execute(
"CREATE TABLE tasks(id TEXT PRIMARY KEY,"
" project_id TEXT, prompt TEXT, status TEXT)")
conn.commit()
migrate_tasks_table(conn)
cols = {r[1] for r in conn.execute(
"PRAGMA table_info(tasks)").fetchall()}
self.assertIn("kind", cols)
# 第二次是 no-op,不炸、不重复。
migrate_tasks_table(conn)
cols2 = {r[1] for r in conn.execute(
"PRAGMA table_info(tasks)").fetchall()}
self.assertEqual(cols, cols2)
finally:
conn.close()
class SandboxSideThreadTestCase(unittest.TestCase):
def test_local_dispatch_no_worktree_flag(self):
calls = []
ex = LocalExecutor("/fake/host.py", runner=_recording(calls))
ex.dispatch_task({"id": "t1", "prompt": "hi"},
no_worktree=True)
self.assertIn("--no-worktree", calls[0])
calls.clear()
ex.dispatch_task({"id": "t1", "prompt": "hi"})
self.assertNotIn("--no-worktree", calls[0])
def test_local_goal_no_worktree_flag(self):
calls = []
ex = LocalExecutor("/fake/host.py", runner=_recording(calls))
ex.goal_task({"id": "t1", "prompt": "目标"}, no_worktree=True)
self.assertIn("--no-worktree", calls[0])
self.assertIn("goal", calls[0])
def test_local_plan_passthrough_readonly(self):
calls = []
ex = LocalExecutor("/fake/host.py", runner=_recording(calls))
ex.dispatch_task({"id": "t1", "prompt": "hi", "plan": 1})
self.assertIn("--sandbox", calls[0])
self.assertEqual(calls[0][calls[0].index("--sandbox") + 1],
"read-only")
calls.clear()
ex.dispatch_task({"id": "t1", "prompt": "hi"})
self.assertNotIn("--sandbox", calls[0])
def test_local_popen_no_worktree_flag(self):
calls = []
class FakePopen(object):
def __init__(self, cmd, **kwargs):
calls.append(list(cmd))
self.returncode = 0
def poll(self):
return 0
def communicate(self, timeout=None):
return "", ""
ex = LocalExecutor("/fake/host.py", runner=_recording([]))
import cloud.sandbox as sandbox_mod
with mock.patch.object(sandbox_mod.subprocess, "Popen",
FakePopen):
ex.popen_task({"id": "t1", "prompt": "hi"},
kind="dispatch", no_worktree=True)
ex.popen_task({"id": "t1", "prompt": "目标"},
kind="goal", no_worktree=True)
dispatch_cmds = [c for c in calls if "dispatch" in c]
goal_cmds = [c for c in calls if "goal" in c]
self.assertEqual(len(dispatch_cmds), 1)
self.assertEqual(len(goal_cmds), 1)
self.assertIn("--no-worktree", dispatch_cmds[0])
self.assertIn("--no-worktree", goal_cmds[0])
def test_docker_dispatch_no_worktree_flag(self):
calls = []
ex = DockerExecutor(
"/fake/host.py", runner=_recording(calls),
env={"XCM_API_KEY": "placeholder-not-a-real-key"})
with mock.patch("cloud.sandbox.shutil.which",
return_value="/usr/bin/docker"):
ex.dispatch_task({"id": "t1", "prompt": "hi"},
no_worktree=True)
self.assertIn("--no-worktree", calls[0])
calls.clear()
with mock.patch("cloud.sandbox.shutil.which",
return_value="/usr/bin/docker"):
ex.dispatch_task({"id": "t1", "prompt": "hi"})
self.assertNotIn("--no-worktree", calls[0])
def test_docker_goal_no_worktree_flag(self):
calls = []
ex = DockerExecutor(
"/fake/host.py", runner=_recording(calls),
env={"XCM_API_KEY": "placeholder-not-a-real-key"})
with mock.patch("cloud.sandbox.shutil.which",
return_value="/usr/bin/docker"):
ex.goal_task({"id": "t1", "prompt": "目标"},
no_worktree=True)
self.assertIn("--no-worktree", calls[0])
class _FakeHostServer(object):
def __init__(self):
self.created = []
def create_thread(self, cwd, model=None, sandbox="workspace-write",
approval_policy="never", instructions=None):
self.created.append((cwd, sandbox))
return "thread-1"
class HostSideThreadTestCase(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self._old_here = host_mod.HERE
host_mod.HERE = self.tmp.name
def tearDown(self):
host_mod.HERE = self._old_here
self.tmp.cleanup()
def test_dispatch_no_worktree_no_repo_uses_scratch(self):
srv = _FakeHostServer()
task = host_mod.dispatch(
srv, {"tasks": {}}, "side-abc", "聊聊", None, None, None,
None, "never", use_worktree=False)
expected = os.path.join(self.tmp.name, "work", "side-threads",
"side-abc")
self.assertEqual(task["cwd"], expected)
self.assertTrue(os.path.isdir(expected))
self.assertFalse(os.path.exists(os.path.join(expected, ".git")))
self.assertIsNone(task["branch"])
self.assertEqual(srv.created[0][0], expected)
# 没有建 host-worktrees 目录。
self.assertFalse(os.path.exists(
os.path.join(self.tmp.name, "work", "host-worktrees")))
def test_dispatch_no_worktree_with_repo_uses_repo(self):
srv = _FakeHostServer()
task = host_mod.dispatch(
srv, {"tasks": {}}, "side-def", "聊聊", "/tmp/some-repo",
None, None, None, "never", use_worktree=False)
self.assertEqual(task["cwd"], "/tmp/some-repo")
self.assertIsNone(task["branch"])
self.assertFalse(os.path.exists(
os.path.join(self.tmp.name, "work", "side-threads")))
if __name__ == "__main__":
unittest.main()
+1 -1
View File
@@ -12,7 +12,7 @@ seeded file -- a dropped middle chapter (01..07) would pass CI silently.
The bidirectional assertion previously discussed on the #209 track is
unmerged, so main does not have it; this file supplies it on the main
base (skill text v0.2, 9 appendix chapters 00..08;
integrated text is v0.5, 11 appendix chapters 00..10).
integrated text is v0.5, 12 appendix chapters 00..11).
What "bidirectional" means here, concretely (all derived from SKILL.md
itself, no new semantics invented):
+93 -2
View File
@@ -6,6 +6,7 @@
执行器 per-call 挂载/网络覆盖、网络创建失败不降级。
"""
import ipaddress
import os
import sys
import tempfile
@@ -15,12 +16,12 @@ from types import SimpleNamespace
from unittest import mock
try:
from cloud.sandbox import DockerExecutor
from cloud.sandbox import DockerExecutor, tenant_subnet_candidates
from cloud.server import init_db
from cloud.worker import Worker
except ImportError:
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from cloud.sandbox import DockerExecutor
from cloud.sandbox import DockerExecutor, tenant_subnet_candidates
from cloud.server import init_db
from cloud.worker import Worker
@@ -118,6 +119,34 @@ class TenantDirsTestCase(unittest.TestCase):
worker._tenant_dirs("projA")
class TenantSubnetCandidatesTestCase(unittest.TestCase):
def test_candidates_inside_allowlist_distinct_and_24(self):
allow = ipaddress.IPv4Network("172.16.0.0/12")
seen = list(tenant_subnet_candidates("codex-p"))
self.assertEqual(len(seen), 32)
self.assertEqual(len(set(seen)), 32)
for item in seen:
net = ipaddress.IPv4Network(item)
self.assertEqual(net.prefixlen, 24)
self.assertTrue(net.subnet_of(allow))
def test_candidates_deterministic_and_name_dependent(self):
first = list(tenant_subnet_candidates("codex-a"))
self.assertEqual(first, list(tenant_subnet_candidates("codex-a")))
# sha256("codex-a") 首字节 59;顺延一格是 60。
self.assertEqual(first[0], "172.16.59.0/24")
self.assertEqual(first[1], "172.16.60.0/24")
self.assertNotEqual(
first[0], list(tenant_subnet_candidates("codex-b"))[0])
def test_candidates_wrap_around_last_slot(self):
# sha256("codex-zz") 首字节 250:槽位到 255 后绕回 0。
seen = list(tenant_subnet_candidates("codex-zz"))
self.assertEqual(seen[0], "172.16.250.0/24")
self.assertEqual(seen[5], "172.16.255.0/24")
self.assertEqual(seen[6], "172.16.0.0/24")
class DockerTenantCommandTestCase(unittest.TestCase):
def test_per_call_mounts_network_and_drop_caps(self):
seen = []
@@ -165,6 +194,68 @@ class DockerTenantCommandTestCase(unittest.TestCase):
env={"XCM_API_KEY": "test-placeholder"})
self.assertFalse(ex.ensure_network("codex-p"))
def test_ensure_network_creates_with_pinned_subnet(self):
seen = []
with mock.patch("cloud.sandbox.shutil.which",
return_value="/usr/bin/docker"):
ex = DockerExecutor(
"/fake/host.py",
runner=_ok_runner_factory(seen),
env={"XCM_API_KEY": "test-placeholder"})
self.assertTrue(ex.ensure_network("codex-p"))
self.assertEqual(len(seen), 1)
# sha256("codex-p") 首字节 138:首候选 172.16.138.0/24。
self.assertEqual(seen[0], ["docker", "network", "create",
"--subnet", "172.16.138.0/24",
"codex-p"])
def test_ensure_network_overlap_probes_next_subnet(self):
seen = []
def overlap_then_ok(cmd, timeout=None):
seen.append(list(cmd))
if len(seen) == 1:
return SimpleNamespace(
returncode=1, stdout="",
stderr="Error response from daemon: Pool overlaps "
"with other one on this address space")
return SimpleNamespace(returncode=0, stdout="", stderr="")
with mock.patch("cloud.sandbox.shutil.which",
return_value="/usr/bin/docker"):
ex = DockerExecutor("/fake/host.py", runner=overlap_then_ok,
env={"XCM_API_KEY": "test-placeholder"})
self.assertTrue(ex.ensure_network("codex-p"))
self.assertEqual(len(seen), 2)
self.assertEqual(seen[0][4], "172.16.138.0/24")
self.assertEqual(seen[1][4], "172.16.139.0/24")
self.assertEqual(seen[1][5], "codex-p")
def test_ensure_network_overlap_exhausted_is_false(self):
seen = []
def always_overlap(cmd, timeout=None):
seen.append(list(cmd))
return SimpleNamespace(
returncode=1, stdout="",
stderr="Error response from daemon: Pool overlaps "
"with other one on this address space")
with mock.patch("cloud.sandbox.shutil.which",
return_value="/usr/bin/docker"):
ex = DockerExecutor("/fake/host.py", runner=always_overlap,
env={"XCM_API_KEY": "test-placeholder"})
self.assertFalse(ex.ensure_network("codex-p"))
self.assertEqual(len(seen), 32)
def test_ensure_network_empty_name_is_false(self):
def must_not_run(cmd, timeout=None):
raise AssertionError("empty name must not call docker")
with mock.patch("cloud.sandbox.shutil.which",
return_value="/usr/bin/docker"):
ex = DockerExecutor("/fake/host.py", runner=must_not_run,
env={"XCM_API_KEY": "test-placeholder"})
self.assertFalse(ex.ensure_network(""))
self.assertFalse(ex.ensure_network(None))
if __name__ == "__main__":
unittest.main()
+110 -8
View File
@@ -529,6 +529,9 @@ def _task_from_row(row):
"coordinator_parts": _coord_parse_parts(_col("coordinator_parts")),
"parent_task_id": _col("parent_task_id"),
"coordinator_part": _col("coordinator_part"),
# 旁聊(Muse 角色对齐 P2):老行缺列/NULL 时按 "main"
# 计(与 server 侧 _task_to_dict 同款惯例)。
"kind": _col("kind") or "main",
# agent 定义 v2:老行缺列时 None = 未用定义,不炸。
"agent_name": _col("agent_name"),
"agent_version": _col("agent_version"),
@@ -3701,6 +3704,7 @@ class Worker:
def _execute_via_process(self, task, kind="dispatch",
deadline_seconds=None, seed_host_dir=None,
no_worktree=False,
quota_check=None):
"""进程模式执行一轮:启动、轮询、取消时杀进程组。
@@ -3724,10 +3728,12 @@ class Worker:
else:
# seed 只对 dispatch/goal 首轮有意义:run 续跑不传
# (文件已在 worktree 里);popen_task 内部同样只在
# dispatch/goal 命令上追加 --seed-dir。
# dispatch/goal 命令上追加 --seed-dir。no_worktree
# 同款只对 dispatch/goal 命令追加 --no-worktree。
proc = self.executor.popen_task(
task, kind=kind, seed_dir=seed_host_dir,
extra_env=gateway_env, model_provider=gateway_provider)
extra_env=gateway_env, model_provider=gateway_provider,
no_worktree=no_worktree)
self._running_set(task_id, proc=proc)
try:
self._set_child_pid(task_id, getattr(proc, "pid", None))
@@ -4592,6 +4598,50 @@ class Worker:
except Exception:
return False
def _writeback_side_thread(self, task, final, text):
"""旁聊终态回写父级(P2 W1):旁聊终局后写一条
task_followups 到父任务(sender_task_id=旁聊 id,
mode='queued',status='pending')。父 running 时排空
循环自动消费(带 _peer_prefix 前缀);父 queued 时
下次认领后首轮排空消费;父终态时行只作记录,
GET /tasks/{id}/followups 与 chat-history 可见,
不复活父任务。正文截 2000 字(与协调员合流报告的
单分工上限同款)。回写是尽力而为:失败返回 False,
不改终局、不重试、不炸执行。"""
parent_id = task.get("parent_task_id")
if not parent_id:
return False
text = (text or "").strip()
if final == "completed":
head, body = "[旁聊结论]", text or "(旁聊无结论文本)"
elif final == "failed":
head, body = "[旁聊失败]", text or "(无错误文本)"
else:
head, body = "[旁聊中断]", text or "(无文本)"
full = "%s %s" % (head, body)
if len(full) > 2000:
full = full[:2000] + "…(已截断)"
try:
conn = _db_connect(self.db_path)
try:
_ensure_followups_table(conn)
conn.execute(
"INSERT INTO task_followups(id, task_id, project_id,"
" body, mode, status, created_by, sender_task_id,"
" created_at, updated_at)"
" VALUES(?, ?, ?, ?, 'queued', 'pending', ?, ?,"
" ?, ?)",
(uuid.uuid4().hex, parent_id,
task["project_id"], full, task.get("created_by"),
task["id"], time.time(), time.time()),
)
conn.commit()
return True
finally:
conn.close()
except Exception:
return False
def _poll_task_snapshot(self, task, poll):
"""轮询一个目标任务:每 interval 查一次库状态,直到终态、
超时或父任务被取消。等待期间不烧模型费,但占着本 worker
@@ -4889,17 +4939,26 @@ class Worker:
child_effort = (create["effort"]
or task.get("child_effort")
or task.get("effort"))
# 旁聊继承(P2 G7):旁聊内 marker 派生
# 的子任务默认继承父 kind='side'(除非
# 块里显式指定 kind),否则子任务会以
# 主聊身份建 worktree/分支,旁聊的无
# worktree 承诺被绕过;执行器已按
# create["executor"] or 父 executor 继承。
child_kind = create.get("kind") or (
"side" if task.get("kind") == "side"
else None)
conn.execute(
"INSERT INTO tasks(id, project_id, prompt,"
" status, idempotency_key, depends_on,"
" priority, created_by, created_at,"
" updated_at, last_heartbeat, executor,"
" model, effort, environment_id,"
" parent_task_id, coordinator_part,"
" parent_task_id, coordinator_part, kind,"
" child_model, child_effort)"
" VALUES(?, ?, ?, 'queued', NULL, '[]',"
" ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, NULL,"
" ?, ?)",
" ?, ?, ?)",
(child_id, task["project_id"],
create["prompt"],
task.get("priority", 0) or 0,
@@ -4910,6 +4969,7 @@ class Worker:
child_effort,
task.get("environment_id"),
task["id"],
child_kind,
task.get("child_model"),
task.get("child_effort")),
)
@@ -5624,6 +5684,22 @@ class Worker:
task_id = task["id"]
result = None
error = None
# 旁聊默认执行(P2 G4):kind='side' 且执行器是 local
# (旁聊默认,未显式要求 docker/cloud)时不建 worktree/
# 分支;显式 docker/cloud 走既有语义。显式 local 与
# 默认 local 在行上不可区分(都是 local),一律走
# 无 worktree 路径。
_side_default = task.get("kind") == "side" and \
(task.get("executor") or "local") == "local"
if _side_default and (task.get("repo") or
getattr(self.executor, "repo", None)):
# 有 repo 的旁聊:cwd 就是 repo,强制 plan=1
# (只读沙箱),不得把 repo 写脏;plan 经
# dispatch --sandbox read-only 透传到 codex。
# 只改内存里的 task dict,不写库(plan 列是任务
# 创建时的属性,旁聊的只读是执行层强制)。
task = dict(task)
task["plan"] = 1
# goal 模式:goal_mode=1(新语义,prompt 即目标)或带
# verify_command(旧语义)时走 host.py goal 循环,不走
# 普通 dispatch。goal 循环内部已多轮重试,worker 级
@@ -5633,7 +5709,10 @@ class Worker:
# 附件 seed:dispatch/goal 首轮才用;run 续跑不传(文件已
# 在 worktree 里)。cloud 执行器没有 worktree 可 seed,
# 创建时本应已拒绝,这里是兜底防线(直接失败,不静默跑)。
seed_host_dir = self._task_seed_dir(task)
# 旁聊默认执行不 seed(创建时已拒 upload_id,附件行恒为空,
# 这里跳过 _task_seed_dir 的一次查库)。
seed_host_dir = None if _side_default \
else self._task_seed_dir(task)
if seed_host_dir and isinstance(self.executor, CloudExecutor):
return ({"executor": "cloud", "task_id": task_id,
"status": "failed", "stdout": "",
@@ -5678,9 +5757,11 @@ class Worker:
_quota_watchdog_thread = None
# P0 三件其一:goal 任务起断点镜像线程(任何执行器);
# 非 goal 任务没有断点语义,不起。终局镜像在 finally 补。
# 旁聊默认执行不起镜像(旁聊的恢复走 thread 续跑,不走
# 断点镜像;旁聊 goal 同款)。
_mirror_stop = None
_mirror_thread = None
if goal_mode:
if goal_mode and not _side_default:
try:
_mirror_stop = threading.Event()
_mirror_thread = threading.Thread(
@@ -5765,6 +5846,7 @@ class Worker:
task, kind=popen_kind,
deadline_seconds=_effective_task_timeout,
seed_host_dir=seed_host_dir,
no_worktree=_side_default,
quota_check=quota_check)
combined = (result.get("stdout", "") or "") + \
(result.get("stderr", "") or "")
@@ -5899,7 +5981,8 @@ class Worker:
result = self.executor.goal_task(
task, seed_dir=seed_host_dir,
extra_env=gateway_env,
model_provider=gateway_provider)
model_provider=gateway_provider,
no_worktree=_side_default)
else:
result = self.executor.goal_task(task)
else:
@@ -5907,7 +5990,8 @@ class Worker:
result = self.executor.dispatch_task(
task, seed_dir=seed_host_dir,
extra_env=gateway_env,
model_provider=gateway_provider)
model_provider=gateway_provider,
no_worktree=_side_default)
else:
result = self.executor.dispatch_task(task)
combined = (result.get("stdout", "") or "") + \
@@ -8968,6 +9052,12 @@ class Worker:
# 只留档);不计数派发(平台/协调员)时为空操作。
self._record_ledger_attempt(task_id, "interrupted",
_ledger_counted)
# 旁聊终态回写(P2 W1):取消也是终态,父级应收到
# 中断说明;回写失败不挡取消返回。
if task.get("kind") == "side":
self._writeback_side_thread(
task, "interrupted",
(result or {}).get("stdout", "") or "cancelled")
return {"dispatched": True, "task_id": task_id,
"host_name": self.executor._host_name(task)
if hasattr(self.executor, "_host_name") else task_id,
@@ -9083,6 +9173,18 @@ class Worker:
message="audit loop wire failed: %s" % exc)
except Exception:
pass
# 旁聊终态回写(P2 W1):只在终局真正写回且未被审计
# 推回(回 queued 不是终态)时写一条 task_followups
# 到父任务;父 running 时排空消费,父 queued 时下次
# 认领后首轮排空消费,父终态时只作记录、不复活父。
# final 已是审计接线后的终局(含 completed/failed/
# interrupted;配额中断的 final 也是 interrupted)。
if (task.get("kind") == "side" and final_written
and not audit_loop_pushback
and final in ("completed", "failed", "interrupted")):
self._writeback_side_thread(
task, final,
stdout if final == "completed" else (error or ""))
# 附件暂存清理:任务终局后删 bundle 目录(重试走已存在
# 续跑,文件已在 worktree 里,不再需要暂存)。
self._cleanup_task_seed(task)
+94
View File
@@ -0,0 +1,94 @@
# Codex 原生 UI ↔ codex-app-server 双向对齐表
- 基线:Gitea main `37ba6876bf326ebd6b1e01ae7d2c5684dafe439b`(2026-11-06,线上 code.tose.top 生产基线)
- 工作区:`~/workspace/codex-app-server-codex-ui`,分支 `feat/codex-native-ui`
- UI 栈:`~/workspace/user/downloads/codex-ui-stack/`(zip sha256 `668bcd82…3125e`);`original/` 2026-10-27 自 `/tmp/codex-asar/webview` 原样 vendored(20918 文件,`index.html` sha `85e5c5b4…`),**必须保持字节一致**,改动只发生在 `host/` 响应层与 app-server 侧
- 清点依据:A(UI host 清点)、C(codex 0.164.0-alpha.9 全量 150 方法实测探活)、D(37ba687 基线重核:控制面 **179 个端点**,控制台为 `cloud/console-react/` 源码构建的 353KB React 包)。B 路按旧分支 5ba4d35 清点的数字(60 端点、820 行 console、resident 为 0)**在本基线全部作废**,未采用。
- codex 二进制:`codex-cli 0.164.0-alpha.9`(`/opt/hatch-image/bin/codex`)
- 约定:凡原生 UI 没有对应表面可承载某功能时,本表如实写“原生无对应表面”,不自造页面/组件。
## 集成架构(结论先行)
1. **线程共享是集成点**:`host.py` 一个 app-server 进程托管多 thread,线程状态持久化在 `CODEX_HOME`(默认 `~/.codex`)。UI host 的 `app-server-bridge.mjs` 自己 spawn 一个 app-server,但因共享同一 CODEX_HOME,`thread/list` 能看到 host.py 任务的 thread(C 实测:`thread/list` 返回了既有 thread)。因此 UI 不需要直连 host.py 的 app-server 进程,也不需要新增 socket/ws 附着逻辑。
2. **入口替换方式**:`GET /console` 改为按需 302 到 UI host(默认 `http://127.0.0.1:8095/`,可配 `CODEX_UI_URL`);UI host 不可达时回落到旧控制台。旧 React 控制台**保留**,新路由 `GET /console-classic` 继续 serve `cloud/console.html`(理由见 §3)。
3. **original/ 不进仓**:解压后 650MB / 20918 文件,不可提交。入仓的只有 `host/` 适配层(约 104KB)与启动脚本;`original/` 由 `CODEX_UI_ORIGINAL_ROOT` 指向已下载的栈目录,部署时以 zip sha256 校验(见 §4)。
4. **模型走 relay,不走 mock**:栈自带的 `relay-config.json` 里 `baseUrl` 指向本地 mock(`http://127.0.0.1:8095/mockrelay/v1`),真实 Bumian 地址只写在 JSON 里、host 代码无引用,`apiKey` 为空。当前配置下模型调用走确定性 mock 替身。接真实 relay 须把 `baseUrl` 改为 `relayBaseUrl` 并经环境变量给 key(只写 envKey 名,key 值绝不入文件)。
---
## A 表——UI 要、codex-app-server / 官方 app-server 缺的
| # | UI 需要的方法/功能 | 缺口现状(实测) | 补齐方案与落点 |
|---|---|---|---|
| A1 | `thread/stop` | 官方 0.164.0-alpha.9 不存在(`unknown variant`,C 实测;实验 schema 枚举 172 方法里也没有它) | 桥接层仿真:`app-server-bridge.mjs` 拦截 `thread/stop`,转调 `turn/interrupt`(host.py:939 已确认:对齐 Codex App 停止按钮就是发 `turn/interrupt`)。落点:入仓的 `cloud/codex-ui-host/host/app-server-bridge.mjs` |
| A2 | `thread/rollback` | 同上,不存在 | 桥接层仿真:转调 `thread/revert`(实测存在)。同上落点 |
| A3 | `thread/startAeon` | 同上,不存在 | 桥接层仿真:转调 `thread/start`(Aeon 为实验模式,本二进制无此入口;降级为普通 start,并在方法注释里写明)。同上落点 |
| A4 | `interactive/liveSessions/list`、`interactive/session/upload`、`interactive/sessionSandbox/list`、`interactive/sessionSandbox/read`(4 个) | 同上,均不存在 | 桥接层仿真:`list` 类回空列表、`upload`/`read` 回结构化 `-32601 method not found (interactive sessions not available in codex 0.164.0-alpha.9)`。不伪造会话数据。UI 的 live-session 面若调用,即表现为“无 live 会话”,不静默成功。落点同上 |
| A5 | 实验协议 29 个方法(UI 清单漏列,二进制实测 schema 里有) | UI 清单只有 150 个;`local-host.js` 按清单动态生成 services,漏列的 29 个 UI 调不到。29 个:`account/bedrock/checkGovCloudRequirements`、`account/sendAddCreditsNudgeEmail`、`account/workspaceMessages/read`、`command/exec/resize|terminate|write`、`environment/add`、`environment/status`、`externalAgentConfig/import/recordHistory`、`plugin/share/checkout`、`server/diagnostics`、`thread/backgroundTerminals/list|terminate`、`thread/decrement_elicitation|increment_elicitation`、`thread/items/read`、`thread/prediction/request`、`thread/readState/update`、`thread/realtime/appendAudio|appendSpeech|appendText|listVoices|start|stop`、`userVerification/cancel|delete|enroll|status|verify` | 把 29 个追加进 `appserver-methods.json`(150 → 179 个,与控制面端点数巧合相同,无关联)。只加清单、不改 `original/`。其中 `realtime/*` 与 `userVerification/*` 在本机未逐个探活,标注“仅 schema 有名,未探活”。落点:入仓的 `cloud/codex-ui-host/host/appserver-methods.json` |
| A6 | 桥接在 Linux 上找到 codex 二进制 | `app-server-bridge.mjs` 的默认候选只有 `$CODEX_APP_SERVER_BIN` 与两个 macOS ChatGPT 内路径;在 Linux 上不设环境变量即报 `codex binary not found`(C 实测) | 入仓的 bridge 增加候选:`$CODEX_BIN`(host.py 同款环境变量名)→ `/opt/hatch-image/bin/codex` → PATH 里的 `codex`。落点同 A1 |
| A7 | UI 要 `git.request("stable-metadata")`(Send 门控靠它非空) | `local-host.js` 里 git 是 stub,固定回 `{available:false, …}`,其余回 null | 桥接层补只读 git 能力:`local-host.js` 的 `git.request` 改为调 host 新增端点 `POST /__codex_git`(只接受 `stable-metadata` / `branch` / `status` 三种只读 op,在给定 cwd 内跑 `git` 只读命令,拒绝写操作与 cwd 外路径)。落点:入仓的 `cloud/codex-ui-host/host/local-host.js` + `web-server.mjs` |
| A8 | UI 要终端面 | `local-host.js:274` 把 `terminal` 服务显式置 null;进程类能力只剩 app-server 的 `process/*` / `thread/shellCommand` | 不补终端面:原生 UI 的终端服务在本栈是 stub,app-server 的 `process/spawn|writeStdin|resizePty|kill` 实测存在(缺参但方法存在),UI 若经 `process/*` 调用即走真实进程。实现时核实 UI 是否调用 `terminal` 服务:若调用,记为“原生终端面在本栈不可用”,不造终端组件。落点:验证阶段核实,不写代码 |
| A9 | UI 要账号 / 用量 / 远控 | `account/rateLimits/read`、`account/usage/read`、`plugin/share/list`、`remoteControl/enable|disable|pairing/start` 需 ChatGPT 账号登录(C 实测);当前 provider(relay/apikey)不用 gateway OAuth,`account/gatewayOAuth/login` 不可用 | **不可补**:这是 provider/账号限制,不是代码缺口。UI 的 `mode-decide` 改写已强制跳过登录直进 app,本栈账号面实际不可达。实现不改动,验收时以实测报错为准 |
| A10 | UI host 无鉴权 vs 控制面要求 `X-API-Key` | UI host 全部端点同源、无鉴权,只绑 `127.0.0.1:8095`;控制面除 `/health`、`/console`、`/oauth/*`、`/webhooks/*` 外一律要求 key | 不给 UI host 加自造登录面:保持 loopback-only 绑定;`/console` 的 302 只发生在控制面已鉴权(或本机回环)的请求之后——实现方案:`cloud/server.py` 的 `/console` 在 302 前先做一次 UI host 健康探测(`GET /` 2 秒超时),探测失败则回落旧控制台;UI host 本身不暴露到外网。密钥不参与 UI host 鉴权 |
| A11 | UI 要 `config/read` 含 relay 覆盖、`model/list` | C 实测均 ok;`config/read` 返回 `model=gpt-6.1-sol`、`model_provider=openkun`(与本机 `~/.codex/config.toml` 一致);`model/list` 仅一条 | 无缺口:桥接的 `-c` 覆盖(`relayArgs`)保持原样;`verify.mjs --deep` 第 13 项即校验此点。实现不改动 |
## B 表——app-server 有、UI 缺的(控制面 179 个端点 → 原生 UI 表面)
控制面分组(D 实测,179 = 任务 66 + 项目/环境/上传 17 + 租户配额/管理 11 + OAuth/身份/key 14 + GitHub App/webhook 7 + resident 7 + automations 10 + 记忆/KB/成本/推送 23 + worker/监控/模型 10 + 其它 full-auto 9 + todos 5):
| # | 控制面功能(端点) | 原生 UI 对应表面 | 结论 |
|---|---|---|---|
| B1 | 任务创建 / 列表 / 详情(`POST /tasks`、`GET /tasks`、`GET /tasks/{id}`) | 线程列表(`thread/list`)与线程阅读(`thread/read`、`thread/items/list`)是**线程**,不是**任务**;任务含 prompt/执行器/模型/goal/分支/worktree 等宿主字段,原生 UI 无任务队列面 | 原生无对应表面(任务列表、建任务表单)。任务的线程内容可经 `thread/read`、`thread/items/list` 看到,属部分映射,不等同 |
| B2 | 任务操作:取消/暂停/重试/合并/发布/提 PR/分叉/继续(`POST /tasks/{id}/{cancel,pause,retry,merge,release,pr,fork,continue}`) | 取消→`turn/interrupt`(+ A1 的 `thread/stop` 仿真);重试/继续→`thread/resume`、`thread/start`;分叉→`thread/fork` | 部分映射:cancel/retry/fork/continue 有方法级对应;pause/release/merge/pr **原生无对应表面**(合并与 PR 是宿主 Git 流程,原生 UI 无此概念) |
| B3 | 任务 trace(`GET /tasks/{id}/trace`、`/trace/stream` SSE)、时间线、成本、结果全文、agents、文件/目录树(`/files`、`/tree`) | `thread/timeline/list`(trace 的部分映射)、`thread/items/list`(内容)、`fs/readDirectory`、`fs/readFile`(文件浏览的部分映射) | 部分映射:trace/时间线/文件有近似方法;成本(`GET /tasks/{id}/cost`)、agents 分工、SSE trace 流 **原生无对应表面** |
| B4 | 任务线程管理(`/threads`、`/thread`、`/thread/{rename,archive,unarchive,compact,message}`、`/chat-history`、`/messages`、followups、reviews、approvals、handoff、refs、worktree、audit-loop、goal、sleep/wait) | `thread/name/set`、`thread/archive|unarchive`、`thread/compact/start`、`thread/items/list`、`review/start`、`thread/approveGuardianDeniedAction` | 部分映射:改名/归档/压缩/评审启动/审批有方法级对应;followups、handoff、refs、worktree 快照、audit-loop 裁决/修复、goal 续跑、sleep/wait、任务间 messages **原生无对应表面** |
| B5 | 浏览器远控(`/tasks/{id}/browser/{state,stream,input,control}`) | 无。原生 UI 的浏览器面是它自己的渲染面,不接管外部浏览器 | 原生无对应表面 |
| B6 | 项目与成员(`POST/GET /projects`、`PATCH /projects/{id}`、`/projects/{id}/members`) | `project/list|read|create|import|update|delete|move` 是 codex 本地工程目录,不是云端多租户项目;成员/角色模型(viewer/contributor/maintainer/owner)原生 UI 没有 | 原生无对应表面(云端项目制与成员管理) |
| B7 | 环境(`POST/GET /environments`、`/environments/{id}/archive`) | `environment/info` 是单机执行环境信息,不是“项目仓库命名基准检出” | 原生无对应表面 |
| B8 | 租户配额 / 管理(`GET /users/me/quota`、`/admin/quotas`、`/admin/sweep`、`/admin/backup`、`/admin/audit-ledger/*`、`/admin/push/dispatch`、`/admin/cost-by-creator`、`/admin/ops/approval-timeout`、`/users/me/cost`) | `account/usage/read`、`account/rateLimits/read` 需 ChatGPT 登录且不可用(A9);其余无 | 原生无对应表面 |
| B9 | OAuth / 身份 / API key(`/oauth/{provider}/*`、`/users/me/keys`、`/users/me/profile`、`/users/me/gateway/rotate`、`/github-app/*`) | `account/login/start`、`account/logout` 存在,但本栈 `mode-decide` 改写跳过登录直进 app,账号面实际不可达;key 管理无 | 原生无对应表面(OAuth 与本站 key 管理) |
| B10 | GitHub webhook / Slack webhook(`POST /webhooks/github|slack`)、GitHub 仓库列表(`GET /github/repos`) | 无 | 原生无对应表面 |
| B11 | resident 常驻监听(7 个端点,`POST/GET/PATCH /projects/{id}/resident-watches`、pause/resume/end) | 无。控制台 `console-react/src/` 全文 0 处 `resident`,连旧控制台都没有这个面 | 原生无对应表面 |
| B12 | automations 与分诊(10 个端点,`/projects/{id}/automations`、bind/heartbeat/cancel、`/triage`) | 无。`automations.py` 的自动化是云端定时建任务;原生 UI 无自动化面(host.py 的本地 automations 是另一套) | 原生无对应表面(bind/heartbeat 连旧控制台都没调) |
| B13 | full-auto(9 个端点,`/full-auto/{status,config-plan,validate-action,merge-gate,ci-fix,release-plan,rollback-plan,execute,audit}`) | 无 | 原生无对应表面 |
| B14 | 项目记忆 / KB / 成本 / 推送(23 个端点,`/projects/{id}/{memory,kb,cost,push/subscriptions}`、`/users/me/memories`、`/users/me/identity-context`、`/users/me/auto-memory*`) | `memory/status|reset` 是 codex 本地记忆状态,不是项目记忆库;`skills/list` 与项目技能清单(`GET /projects/{id}/skills`)部分对应 | 部分映射:项目技能→`skills/list`;其余(项目记忆/KB/成本/推送订阅/自动记忆)**原生无对应表面** |
| B15 | 技能 / 应用 / MCP(`GET /projects/{id}/{skills,apps,mcp-servers}`、`POST …/mcp-servers/reload|oauth-login`) | `skills/list`、`plugin/list|installed`、`app/list|read`、`mcpServerStatus/list`、`mcpServer/tool/call`、`config/mcpServer/reload` | 覆盖:技能/插件/应用/MCP 有方法级对应(`plugin/read` 失败时 host 有离线回落) |
| B16 | worker / 监控 / 模型(`GET /workers`、`/workers/heartbeat`、`/metrics`、`/metrics-report`、`/audit-scorecard`、`/routing-shadow`、`/models`、`/relay/models`) | `model/list`、`modelProvider/capabilities/read` 覆盖模型面;其余无 | 部分映射:模型列表有对应;worker 池、指标、评分卡、路由影子 **原生无对应表面** |
| B17 | 待办(5 个端点,`/projects/{id}/todos`) | 无 | 原生无对应表面 |
| B18 | 模型总览与中转模型列表(`GET /models`、`GET /relay/models`) | `model/list`(C 实测仅一条 `gpt-6.1-sol`) | 部分映射:单模型列表有对应;中转站多模型下拉无原生面(旧控制台用 `/relay/models` 做模型下拉,新 UI 用 `model/list`) |
| B19 | 旁聊会话列表(`kind='side'` 任务:`POST /tasks` 带 `kind` + `parent_task_id`、`GET /tasks?kind=side`、`GET /tasks?parent_task_id=&kind=side`) | 无。原生 UI 只有线程列表,无“主聊 + 旁聊”分组、无 kind 筛选、无“开旁聊”入口 | 原生无对应表面(旁聊会话列表仅 `/console-classic`) |
## §3 旧 console.html 的去留决定
**决定:保留旧控制台,改路由为 `GET /console-classic`,`GET /console` 改为新 UI 入口(302 到 UI host,不可达时回落旧控制台)。**
理由:
1. 旧控制台是 React 构建产物(`cloud/console-react/`,46 个源码文件),承载 B 表里大量“原生无对应表面”的管理面(配额/管理、resident、automations、full-auto、审计、成员、待办等)。悄悄删除等于删除功能,违反“不删除较新工作”。
2. 原生 UI 是单用户线程界面,无法承载多租户控制面;“替换”指的是默认入口,不是不留后路。
3. `test_console.py`(5 个测试)只验壳与路由,不跑浏览器 JS;路由改动须同步更新这 5 个测试(`/console` 无 key 200 出 HTML 的断言要改为 302/回落行为,`/console-classic` 承接原断言),并新增 UI host 相关测试。
## §4 入仓清单(实现范围)
1. `cloud/codex-ui-host/`:自 `~/workspace/user/downloads/codex-ui-stack/` 拷入 `host/` 全部 12 个文件 + `start.sh` + `verify.mjs`(原样,改动只在下述适配处;`original/` 不入仓)。
- 适配 A1–A4:`app-server-bridge.mjs` 增加 7 个缺失方法的仿真分支。
- 适配 A5:`appserver-methods.json` 追加 29 个方法(150 → 179)。
- 适配 A6:`app-server-bridge.mjs` 增加 `$CODEX_BIN`、`/opt/hatch-image/bin/codex`、PATH 候选。
- 适配 A7:`local-host.js` 的 `git.request` 走新增 `POST /__codex_git`(只读三 op);`web-server.mjs` 增加该端点。
- 适配:`relay-config.json` 保持栈自带 Bumian 配置原样(`apiKey` 为空),另加 `relay-config.README.md` 说明 mock 现状与接真实 relay 的两步(`baseUrl` 改为 `relayBaseUrl` + 设 `BUMIAN_API_KEY` 环境变量);不换 provider、不写 key。
- 适配:`projectless.mjs` 的默认工作区根在 Linux 上改为 `$CODEX_WEB_WORKSPACE_ROOT` 或 `~/Codex`(原 `~/Documents/Codex` 在服务器上不存在)。
2. `cloud/server.py`:`GET /console` 改为 302 到 `CODEX_UI_URL`(默认 `http://127.0.0.1:8095/`),2 秒探测失败回落旧控制台;新增 `GET /console-classic` serve `cloud/console.html`(原 `/console` 行为整体平移)。
3. `scripts/run-codex-ui.sh`(新建):启动 UI host 的唯一入口,设置 `CODEX_APP_SERVER_BIN`、`CODEX_UI_ORIGINAL_ROOT`(默认已下载的栈目录),`exec node cloud/codex-ui-host/host/web-server.mjs --port ${UI_PORT:-8095}`。
4. 测试:`cloud/tests/test_console.py` 按 §3 更新;新增 `cloud/tests/test_codex_ui_host.py`(Python 侧:方法表计数 179、7 个仿真方法在 bridge 里存在、original/ 未被入仓——即仓内无 `cloud/codex-ui-host/original/`、启动脚本存在且可执行)。
5. 验证:`python3 run_tests.py cloud` 相关子集 + `node verify.mjs`(shallow)+ `node verify.mjs --deep`(真实 app-server 可用时)+ 真实联调冒烟(起 :8095,`thread/list` 与等价调用,日志为证)。
## §4.1 实现注记(实现后补记,与规范同等效力)
- A5:`appserver-methods.json` 是纯 JSON,加文件头注释会破坏合法性,故注释未写进 JSON 本身;“`realtime/*` 与 `userVerification/*` 仅 schema 有名、未逐个探活”记在这里(本节),与 A5 表格同效。
- A4 口径:以本表 A4 为准——`list` 类(`interactive/liveSessions/list` 与 `interactive/sessionSandbox/list`)回空列表;`upload` 与 `sessionSandbox/read` 回结构化 `-32601`。实现初版曾把 `sessionSandbox/list` 划入 `-32601` 组,协调员已按本表纠正。
- `web-server.mjs` 的 `--root` 默认值增加了 `CODEX_UI_ORIGINAL_ROOT` 回落(1 行):不加则启动脚本设该变量无效;接受该改动,启动脚本无需改传 `--root`。
## §5 剩余缺口(实现后仍存在,如实记录)
- B 表里标记“原生无对应表面”的功能(B1 任务队列、B6 项目制、B7 环境、B8 配额/管理、B9 OAuth/key、B10 webhook、B11 resident、B12 automations、B13 full-auto、B14 项目记忆/KB/成本/推送大部、B17 待办、B5 浏览器远控)**没有**原生界面承载,仍走 `/console-classic`(旧 React 控制台)。这是按用户要求不自造界面的直接结果,不是遗漏。
- A9(ChatGPT 登录类方法)不可补;A8 终端面待验证阶段核实。
- `original/` 不进仓:部署时须另行提供与 zip sha256 一致的 `original/` 目录,否则 UI host 无法启动;这一步属于部署(须用户点头),不在本轮本地完成范围内。
+12
View File
@@ -2588,6 +2588,13 @@ def dispatch(server, state, name, prompt, repo, model, effort, sandbox,
branch = None
if use_worktree and repo:
cwd, branch = make_worktree(repo, name)
if not use_worktree and not repo:
# 旁聊无 repo:create_thread 不接受 cwd=None
# (os.path.abspath(None) 抛错),以 per-task scratch
# 目录为 cwd(普通目录,非 git worktree,不建分支;
# host 任务名即任务 id 的安全化形式,目录按任务隔离)。
cwd = os.path.join(HERE, "work", "side-threads", name)
os.makedirs(cwd, exist_ok=True)
if seed_dir:
# 附件只能进 worktree:直接以 repo 为 cwd 时拷进去等于污染
# 仓库,必须 loud fail,绝不静默放行。
@@ -3259,6 +3266,10 @@ def main():
p.add_argument("--repo", default=None)
p.add_argument("--seed-dir", default=None,
help="附件暂存目录:派发时拷进 worktree 再跑 turn")
p.add_argument("--no-worktree", action="store_true",
help="不建 worktree/分支:无 repo 时以 per-task"
" scratch 目录为 cwd,有 repo 时以 repo 为"
" cwd(旁聊默认,见 dispatch 同款)")
p.add_argument("--verify", default=None,
help="验收命令;不传时走模型自决:模型在回合末尾"
"单独一行输出“目标已达成”即达标")
@@ -4208,6 +4219,7 @@ def main():
task = dispatch(srv, state, args.name, args.task, args.repo,
args.model, args.effort, args.sandbox,
args.approval_policy,
use_worktree=not args.no_worktree,
seed_dir=args.seed_dir)
save_state(args.state, state)
# 新开循环的 spawn goal 是权威的:上一轮同名循环
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
# Start the Codex native UI host — the only entry point for the UI host.
#
# scripts/run-codex-ui.sh UI on :8095
# UI_PORT=9000 scripts/run-codex-ui.sh custom port
#
# Env (all optional, no secrets in this file):
# CODEX_APP_SERVER_BIN codex binary for the app-server bridge
# (default /opt/hatch-image/bin/codex)
# CODEX_UI_ORIGINAL_ROOT vendored original/ webview directory
# (default ~/workspace/user/downloads/codex-ui-stack/original;
# original/ is never committed — see docs/codex-native-ui-alignment.md §4)
# UI_PORT port to listen on (default 8095)
# BUMIAN_API_KEY real-relay key, only when relay-config.json points
# at the real relay (see host/relay-config.README.md)
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
UI_PORT="${UI_PORT:-8095}"
export CODEX_APP_SERVER_BIN="${CODEX_APP_SERVER_BIN:-/opt/hatch-image/bin/codex}"
export CODEX_UI_ORIGINAL_ROOT="${CODEX_UI_ORIGINAL_ROOT:-$HOME/workspace/user/downloads/codex-ui-stack/original}"
export CODEX_WEB_PORT="$UI_PORT"
echo "[codex-ui] UI http://127.0.0.1:${UI_PORT}/"
echo "[codex-ui] codex ${CODEX_APP_SERVER_BIN}"
echo "[codex-ui] root ${CODEX_UI_ORIGINAL_ROOT}"
exec node "$REPO_ROOT/cloud/codex-ui-host/host/web-server.mjs" --port "${UI_PORT}"