diff --git a/.github/workflows/package-smoke.yml b/.github/workflows/package-smoke.yml
index 7e2d9399ef..4f9014e42f 100644
--- a/.github/workflows/package-smoke.yml
+++ b/.github/workflows/package-smoke.yml
@@ -4,6 +4,9 @@ on:
pull_request:
paths:
- ".github/workflows/package-smoke.yml"
+ - "packages/loopx-core/**"
+ - "loopx/**"
+ - "tests/test_core_distribution.py"
- "packages/loopx-repo-health/**"
- "packages/loopx-obelisk/**"
- "packages/loopx-codex-provider-routing/**"
@@ -14,6 +17,9 @@ on:
- main
paths:
- ".github/workflows/package-smoke.yml"
+ - "packages/loopx-core/**"
+ - "loopx/**"
+ - "tests/test_core_distribution.py"
- "packages/loopx-repo-health/**"
- "packages/loopx-obelisk/**"
- "packages/loopx-codex-provider-routing/**"
@@ -28,6 +34,33 @@ concurrency:
cancel-in-progress: true
jobs:
+ core:
+ name: loopx-core
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ steps:
+ - uses: actions/checkout@v7
+ - uses: actions/setup-python@v6
+ with:
+ python-version: "3.11"
+ - uses: actions/setup-node@v6
+ with:
+ node-version: "26.0.0"
+ - uses: astral-sh/setup-uv@v7
+ - name: Prepare source validation environment
+ run: uv sync --extra test
+ - name: Build and separately install the standalone core
+ run: |
+ uv run --extra test python packages/loopx-core/prepare.py --output "$RUNNER_TEMP/core-source"
+ uv build "$RUNNER_TEMP/core-source" --out-dir "$RUNNER_TEMP/core-dist"
+ uv venv "$RUNNER_TEMP/core-env" --python .venv/bin/python
+ uv pip install --python "$RUNNER_TEMP/core-env/bin/python" "$RUNNER_TEMP"/core-dist/loopx_core-*.whl
+ echo "LOOPX_CORE_TEST_PYTHON=$RUNNER_TEMP/core-env/bin/python" >> "$GITHUB_ENV"
+ - name: Qualify installed execution, recovery, replay and refusal
+ env:
+ LOOPX_USAGE_PING: "0"
+ run: uv run --extra test python -m pytest -q tests/test_core_distribution.py
+
codex-provider-routing:
name: loopx-codex-provider-routing
runs-on: ubuntu-latest
diff --git a/README.md b/README.md
index 6aab04842a..eb190b352d 100644
--- a/README.md
+++ b/README.md
@@ -6,14 +6,14 @@
**Start with one goal. Keep long-running agent work verifiable.**
-Tell the Agent you already use what you want done. LoopX keeps the goal,
-work, approvals and evidence durable behind the scenes, so the next session can
-continue without making you learn LoopX internals first.
-Work with Codex, Claude Code, DeepSeek Harness and other runtimes. Advanced docs still expose the local-first semantic kernel, ownership model and research workbench.
+Tell the Agent you already use what you want done. LoopX keeps the goal, work,
+needed decisions and verifiable results durable behind the scenes, so the next
+session can continue without making you learn LoopX internals first.
+Work with Codex, Claude Code, DeepSeek Harness and other runtimes. Advanced docs still expose the local-first architecture, ownership model and research workbench.
[](https://github.com/loopx-project/loopx/releases/latest) [](LICENSE) [](docs/architecture/rfcs/typescript-control-plane-migration-v0.md) [](pyproject.toml)
-[](docs/public-private-boundary.md) [](docs/product/release-readiness.md) [](https://github.com/loopx-project/loopx/graphs/contributors) [](https://discord.gg/XmGgQyCFZd) [](https://dsh-plugin.org/plugins/loopx-project/loopx)
+[](docs/public-private-boundary.md) [](docs/product/release-readiness.md) [](https://github.com/loopx-project/loopx/graphs/contributors) [](https://discord.gg/XmGgQyCFZd) [](https://dsh-plugin.org/plugins/loopx-project/loopx)
[Get started](#quick-start) · [Why LoopX](#why-loopx) · [See it in action](#see-loopx-in-action) · [Evidence](#evidence) · [Contribute](#contributing) · [Docs](https://loopx-project.github.io/loopx/docs/) · [简体中文](README.zh-CN.md)
@@ -41,9 +41,10 @@ then send one natural-language goal:
$loopx speed up this project's slowest workflow; compare approaches and verify the gains
```
-LoopX turns that into visible tasks, gates and evidence as needed. Claude Code
-uses `/loopx `; see [host setup](#start-from-your-agent) for other
-runtimes. Prefer a guided first run? [Choose a task below](#try-loopx).
+LoopX turns that into a visible plan, decisions that need you, and verification
+as needed. Claude Code uses `/loopx `; see
+[host setup](#start-from-your-agent) for other runtimes. Prefer a guided first
+run? [Choose a task below](#try-loopx).
## 📰 News
@@ -54,10 +55,10 @@ runtimes. Prefer a guided first run? [Choose a task below](#try-loopx).
dates (UTC). -->
- **2026-10-25 · 🤝 [Long-horizon agent research: collaborators welcome](https://loopx-project.github.io/loopx/blog/edgebench-feedback-and-memory/)** — Exploring **breakthroughs in long-horizon outcomes under equal budgets**, through feedback, replanning and reproducible evaluation on EdgeBench and real-world tasks. Academic labs and industry teams are welcome to collaborate, comparing outcomes and total cost. [Discuss collaboration](https://github.com/loopx-project/loopx/discussions).
-- **2026-10-20 · 🚀 [LoopX 2.0](https://github.com/loopx-project/loopx/releases/tag/v2.0.0)** — A shared TypeScript control plane and long-horizon research workbench. [Migration guide](docs/product/releases/v2.0.0.md) · [Engineering story](https://loopx-project.github.io/loopx/blog/typescript-control-plane-refactor/).
+- **2026-10-20 · 🚀 [LoopX 2.0](https://github.com/loopx-project/loopx/releases/tag/v2.0.0)** — A shared TypeScript state layer and long-horizon research workbench. [Migration guide](docs/product/releases/v2.0.0.md) · [Engineering story](https://loopx-project.github.io/loopx/blog/typescript-control-plane-refactor/).
- **2026-10-14 · 🤖 [Agentic software](https://loopx-project.github.io/loopx/blog/semantic-software-development/)** — Connecting goals, evidence and human feedback to engineering delivery.
- **2026-10-02 · 🧠 [Long-horizon bootstrapping & RSI](https://loopx-project.github.io/loopx/blog/long-horizon-rsi/)** — Four months of developing LoopX with LoopX: public project history and a design perspective on system-level self-improvement.
-- **2026-09-18 · 🧪 [LoopX × LHTB](https://loopx-project.github.io/loopx/benchmarks/lhtb/)** — A 46-task, same-model study provides empirical support for **trading more compute for better outcomes**: LoopX Heartbeat mean reward is 17.3% above Plain Codex and 10.6% above native Goal.
+- **2026-09-18 · 🧪 [LoopX × LHTB](https://loopx-project.github.io/loopx/benchmarks/lhtb/)** — A 46-task, same-model study provides empirical support for **trading more compute for better outcomes**: LoopX 1.0.3 Heartbeat continued runs reach mean reward 17.3% above Plain Codex and 10.6% above native Goal.
- **2026-09-06 · 🎉 [LoopX 1.0](https://github.com/loopx-project/loopx/releases/tag/v1.0.0)** — A Personal Agent Workspace to follow agent work, act on decisions, and inspect delivered results across sessions. [Workspace guide](https://github.com/loopx-project/loopx/blob/v1.0.0/docs/guides/personal-workspace-user-guide.md).
- **2026-08-02 · ⏱️ [200+ hour trajectories shared on X](https://x.com/huangruiteng/status/2083904257494024425)** — Early owner-run examples spanning 200+ elapsed hours drew broad attention on X, showing work across execution, waits, human decisions and recovery. [Trajectories and limits](#open-source-issue-fix).
@@ -78,12 +79,12 @@ hand work to peers. A timer can keep a loop running without making it useful.
- **Continue without starting over.** Keep the Goal, accepted requirements,
decisions and evidence across sessions and restarts.
-- **[Coordinate without losing ownership.](https://loopx-project.github.io/loopx/blog/agent-facing-kanban/#walkthrough)** Connect delegated work, dependent
+- **[Coordinate without dropping context.](https://loopx-project.github.io/loopx/blog/agent-facing-kanban/#walkthrough)** Connect delegated work, dependent
artifacts, review and returned results while keeping responsibility explicit.
- **Judge progress by what changed.** Inspect deliverables, supporting and
refuting evidence, costs and the next decision—not just activity.
-Your Agent runtime supplies the model and tools. LoopX’s semantic control plane
+Your Agent runtime supplies the model and tools. LoopX’s durable state layer
preserves goals, acceptance, decisions, ownership and evidence across sessions
and runtimes for engineering, research and operations. Agents and people use
those facts to reassess what to do next. Human authorization still governs
@@ -97,25 +98,25 @@ protected actions.
| Manage personal projects, schedules and decisions in one place | [Personal Agent Workspace](#meet-the-personal-agent-workspace) |
| Let agents collaborate and deliver verifiable results | [Agent collaboration guide](docs/product/use-cases/cross-runtime/README.md) |
-LoopX keeps the durable control state in one compact layer:
+LoopX keeps the durable work state in one compact layer:
-Agent runtimes execute the work. LoopX governs the state that lets engineering,
+Agent runtimes execute the work. LoopX keeps the state that lets engineering,
research, discovery, and operations loops continue across runs. It is not
another agent framework or a provider-specific orchestration runtime.
+
+Advanced: how the control layer works and where it fits
+
-
+
-
-How the control plane works and where it fits
-
A useful mental model is an
**[agent-native Kanban for long-running work](docs/development/control-plane-course/00-concept-primer.md)**.
Cards carry identity, authority, evidence, and continuation. Moves are validated
diff --git a/apps/presentation/dashboard/smoke/bound-conversation-smoke.ts b/apps/presentation/dashboard/smoke/bound-conversation-smoke.ts
index 6b840f5af5..b7a61c4567 100644
--- a/apps/presentation/dashboard/smoke/bound-conversation-smoke.ts
+++ b/apps/presentation/dashboard/smoke/bound-conversation-smoke.ts
@@ -132,6 +132,11 @@ try {
assert.equal(complete.messages.length, expectedRows);
const recoveredOffsets: number[] = [];
const failedOffsets = new Set();
+ const interruptedHistoryBody = (status: number) => new Response(new ReadableStream({
+ start(controller) {
+ controller.error(new Error("Synthetic interrupted history response body"));
+ },
+ }), {status});
globalThis.fetch = async (input, init) => {
const response = await routedRead(input, init);
if (new URL(String(input), origin).pathname !== `/api/chat/sessions/${sessionId}`) return response;
@@ -140,7 +145,8 @@ try {
recoveredOffsets.push(offset);
if (offset > 0 && !failedOffsets.has(offset)) {
failedOffsets.add(offset);
- throw new Error("Synthetic interrupted transport after a verified prefix");
+ if (offset === 200) throw new Error("Synthetic interrupted transport after a verified prefix");
+ return interruptedHistoryBody(response.status);
}
return response;
};
@@ -150,6 +156,8 @@ try {
assert.deepEqual(interrupted.snapshots, []);
const interruptedAgain = await fetchChatHistory(options, interrupted);
assert.deepEqual(interruptedAgain.messages, []);
+ assert.equal(interruptedAgain.pendingReads?.get(sessionId)?.messages.length, 400,
+ "A lost body after successful response headers keeps the verified prefix private");
const recovered = await fetchChatHistory(options, interruptedAgain);
assert.deepEqual(recovered.unavailableSessionIds, []);
assert.deepEqual(recovered.snapshots[0].messages, complete.messages);
@@ -188,11 +196,12 @@ try {
child.stdin.write("recover\n"); assert.equal((await next()).source_verified, true);
// Permanent denial, a changed source or malformed prefix cannot be retained
// as transport progress. The following read must validate page zero again.
- for (const fault of ["denied", "source", "digest"]) {
+ for (const fault of ["denied", "denied-body", "source", "digest"]) {
globalThis.fetch = async (input, init) => {
const response = await routedRead(input, init);
if (!new URL(String(input), origin).searchParams.has("message_cursor")) return response;
if (fault === "denied") return new Response("{}", {status: 403});
+ if (fault === "denied-body") return interruptedHistoryBody(403);
const value = await response.json();
if (fault === "source") value.session.goal_id = "unrelated-goal";
else value.message_page.snapshot_sha256 = "0".repeat(64);
diff --git a/apps/presentation/dashboard/smoke/conversation-reply-browser-smoke.mjs b/apps/presentation/dashboard/smoke/conversation-reply-browser-smoke.mjs
index a47c2ca3b9..9192c131a2 100644
--- a/apps/presentation/dashboard/smoke/conversation-reply-browser-smoke.mjs
+++ b/apps/presentation/dashboard/smoke/conversation-reply-browser-smoke.mjs
@@ -29,6 +29,25 @@ function next() {
if (queue.length) receive(queue.shift()); else waiting.push(receive);
});
}
+async function openWhileCheckingOriginalConversation(ready, page) {
+ const isOriginalSource = url => url.pathname === "/api/chat/sessions"
+ && url.searchParams.get("conversation_binding_id") === ready.bindingId;
+ const {promise: allowSourceRead, resolve: finishSourceRead} = Promise.withResolvers();
+ const holdOriginalSource = async route => {
+ await allowSourceRead;
+ await route.continue();
+ };
+ await page.route(isOriginalSource, holdOriginalSource);
+ try {
+ await page.goto(ready.origin + "/chat/", {waitUntil: "domcontentloaded"});
+ await page.getByText("正在核验原会话…", {exact: true}).waitFor({timeout: 4_000});
+ assert.equal(await page.getByTestId("runtime-readiness").count(), 0,
+ "Checking the original conversation must not show machine runtime setup");
+ } finally {
+ finishSourceRead();
+ await page.unrouteAll({behavior: "wait"});
+ }
+}
async function checkReturnedFiles(ready, page, errors, writes) {
const snapshot = await (await page.request.get(`${ready.origin}/api/chat/sessions/${ready.sessionId}`)).json();
const answer = snapshot.messages.find(row => row.message_id === ready.fileMessageId);
@@ -113,7 +132,11 @@ try {
const snapshot = await (await page.request.get(`${ready.origin}/api/chat/sessions/${ready.sessionId}`)).json();
const source = snapshot.messages.find(row => row.role === "user" && row.turn_id);
assert.ok(source.text.startsWith("Referenced message ("), "Canonical model input stays unchanged");
- await page.goto(ready.origin + "/chat/");
+ if (process.env.LOOPX_REPLY_BEFORE === "1") {
+ await page.goto(ready.origin + "/chat/");
+ } else {
+ await openWhileCheckingOriginalConversation(ready, page);
+ }
await page.getByRole("navigation", {name: "管家视图"}).getByRole("button", {name: /^(Chat|对话)$/}).click();
const body = "请比较这次修改,并保留原话题中的限制。Compare this change against the original constraints.";
const parent = "原要求:保留已有结果和失败记录。Original requirement: retain results and failed attempts.";
diff --git a/apps/presentation/dashboard/src/data/chat.ts b/apps/presentation/dashboard/src/data/chat.ts
index d7c740c9f6..95f19d780f 100644
--- a/apps/presentation/dashboard/src/data/chat.ts
+++ b/apps/presentation/dashboard/src/data/chat.ts
@@ -1297,8 +1297,10 @@ function sameHistoryRevision(current: ChatSessionSummary, read: ChatSessionSumma
}
function historyTransportUnavailable(error: unknown) {
- return error instanceof ChatApiError && error.payload.error_code === "chat_api_unavailable"
- && (error.payload.http_status === undefined || Number(error.payload.http_status) >= 500);
+ if (!(error instanceof ChatApiError) || error.payload.error_code !== "chat_api_unavailable") return false;
+ const status = error.payload.http_status;
+ // A response body can be interrupted after successful history headers.
+ return status === undefined || status === 200 || Number(status) >= 500;
}
export type ChatHistory = {
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/attached-agent-conversations.tsx b/apps/presentation/dashboard/src/features/personal-workspace/attached-agent-conversations.tsx
index 16e16b06da..29de78c83c 100644
--- a/apps/presentation/dashboard/src/features/personal-workspace/attached-agent-conversations.tsx
+++ b/apps/presentation/dashboard/src/features/personal-workspace/attached-agent-conversations.tsx
@@ -16,7 +16,12 @@ type LiveObservation = {kind: "records"; revision: number; records: Message[]} |
{kind: "unavailable"; retry: boolean};
class ConversationRequestError extends Error {
- constructor(message: string, readonly unsubmittedOperation?: string, readonly httpStatus?: number) {super(message);}
+ constructor(
+ message: string,
+ readonly unsubmittedOperation?: string,
+ readonly httpStatus?: number,
+ readonly interruptedSuccessfulResponse = false,
+ ) {super(message);}
}
export async function request(url: string, options?: RequestInit): Promise {
@@ -30,11 +35,9 @@ export async function request(url: string, options?: RequestInit): Promise
try {
body = await response.text();
} catch {
- // The headers arrived, so the status is already known even though the body
- // did not. Drop the status here and a stalled 503 body looks like a plain
- // transport failure while a truncated 403 body looks retryable; the caller
- // must classify on this status, not on how the body read failed.
- throw new ConversationRequestError("Conversation unavailable", undefined, response.status);
+ // Preserve rejected statuses even when their bodies drop. A successful
+ // response interrupted in transit is different from a complete invalid body.
+ throw new ConversationRequestError("Conversation unavailable", undefined, response.status, response.ok);
}
let value: Record | null = null;
try {value = body ? JSON.parse(body) as Record : null;} catch {value = null;}
@@ -51,7 +54,8 @@ function interruptedRead(failure: unknown): boolean {
}
export function retryableRead(failure: unknown): boolean {
- return interruptedRead(failure) || (failure instanceof ConversationRequestError && (failure.httpStatus ?? 0) >= 500);
+ return interruptedRead(failure) || (failure instanceof ConversationRequestError &&
+ (failure.interruptedSuccessfulResponse || (failure.httpStatus ?? 0) >= 500));
}
export function readFailureMessage(failure: unknown, zh: boolean): string {
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-research-analysis.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-analysis.tsx
index 1e55e0f8d5..f52ffbcfb3 100644
--- a/apps/presentation/dashboard/src/features/personal-workspace/goal-research-analysis.tsx
+++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-analysis.tsx
@@ -634,7 +634,7 @@ export function ResearchAnalysisPanel({analysis: a, onHistory}: {analysis: Contr
return
{zh ? "已保存的分析、原生测量与 Explore 历程分别读取。这个 Goal 没有已记录的 Explore 历程;已保存的结果仍可在下方阅读。" : "Saved analysis, native measurements and Explore history are read separately. This Goal has no recorded Explore history; its saved results remain readable below."}
{copy.densityHint}{zh ? "。" : ". "}{zh ? "无记录空档不代表研究停止。" : "A recording gap does not establish that research stopped."}
-
{zh ? "横轴从首条记录算起。悬停刻度可查看对应的本地时间。" : "The axis starts at the first record. Hover over a tick for its local time."}
+ {complete ?
{phaseNote}
: null}
+
{complete ? copy.densityHint : copy.partialDensityHint}{zh ? "。" : ". "}{zh ? "无记录空档不代表研究停止。" : "A recording gap does not establish that research stopped."}
+
{zh ? "悬停刻度可查看对应的本地时间。" : "Hover over a tick for its local time."}
{findingCopy.meaning}
{!timeline.structuredCount ?
{copy.unstructuredHint}
: null}
{copy.routeHint}{zh ? "。" : ". "}{zh ? "相邻点合拢,数字为研究项数,一次展开原始时间与依据。" : "Nearby points group; the number counts research items. Select once for original times and sources."}
-
{zh ? "选中时段后,高亮其中有记录的研究项。各项位置仍按首次记录时间。" : "Selecting a period highlights items with records in it. Each item stays at its first recorded time."}
+ {complete ?
{zh ? "选中时段后,高亮其中有记录的研究项。各项位置仍按首次记录时间。" : "Selecting a period highlights items with records in it. Each item stays at its first recorded time."}
: null}
{zh ? "未关联问题按首次记录 UTC 日期收纳,不推断路线或因果。决策轨迹按研究问题归组,组内保留各轮显式决策,最近记录在前;同名问题不会自动合并。" : "Unlinked questions group by first-record UTC date, without inferring a route or cause. Decision history groups explicit decisions by question, newest record first; matching titles do not merge questions."}