diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9b0cd713b6..ef7cf0ea2f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -189,6 +189,7 @@ touch, mention it in the PR instead of fixing it there. | `postgresql-integration.yml` | control-plane or npm lockfile paths | no | PostgreSQL authority store and service on a temporary instance | | `package-smoke.yml` | extension package paths | no | extension packages install, entrypoints, and example schemas | | `release-artifacts.yml` | `loopx/`, packaging, and lockfile paths | no | release identity and a release build from this source | +| `runtime-snapshots.yml` | runtime artifact, installer, and updater paths | no | qualified, revision-addressed runtime archives and the main channel pointer | | `ark-turn.yml` | Turn driver and collaboration paths | no | optional Ark Turn package, stdio MCP, and DSH parity | | `memt-turn.yml` | optional MemT package, Turn driver, lock, and packaging paths | no | explicit optional dependency installation, complete adapter workflow, stdio MCP, and driver binding | | `dsh-plugin.yml` | DSH plugin and distribution workflow paths | no | Linux/Windows package install and uninstall, typed host contracts, and Linux real LoopX/web runtime admission | diff --git a/README.md b/README.md index 7a1e62d775..9ddca0d0d6 100644 --- a/README.md +++ b/README.md @@ -23,19 +23,24 @@ A local-first [semantic control plane](https://loopx-project.github.io/loopx/blo ## Start in two steps -With Python 3.11+ and Node.js 22.22.3+ (24 LTS recommended), install once: +**1. Install LoopX and its Agent skills.** Requires Python 3.11+ and +Node.js 22.22.3+ (24 LTS recommended). ```bash python3 -m pip install --upgrade loopx && loopx workflow-skills --install ``` -Restart your Agent host, open your project, then give Codex a concrete task: +[Install options and upgrades](docs/guides/installing-loopx.md) + +**2. Give your Agent a concrete task.** Restart your Agent host, open your +project, then send this in Codex: ```text $loopx speed up this project's slowest workflow; compare approaches and verify the gains ``` -Claude Code uses `/loopx `. [Other hosts, install options and upgrades ↓](#try-loopx) +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 @@ -80,7 +85,7 @@ protected actions. | What you want to do | Start here | | --- | --- | -| Keep a coding or research agent working across sessions | [Install and start](#try-loopx) | +| Keep a coding or research agent working across sessions | [Install and start](#quick-start) | | 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) | @@ -204,35 +209,34 @@ from this reproducible workspace. [Scenarios and replay instructions](demo/works ## Try LoopX -Install once: +Choose a walkthrough with a result you can inspect: -```bash -python3 -m pip install --upgrade loopx && loopx workflow-skills --install -``` +| Start with | What you'll do | +| --- | --- | +| [Your first bug-fixing loop](docs/guides/first-bugfix-loop.md) | Reproduce two bugs in a small Python project, let the Agent fix them, then check the tests and patch. | +| [Long-running coding work](docs/guides/long-running-coding-agents.md) | Fix an issue or keep improving your own repository, with review, evidence and recovery across sessions. | +| [Personal Agent Workspace (中文)](docs/guides/personal-workspace-user-guide.md) | Follow the illustrated guide to manage projects, conversations, tasks and delivered results in the App. | ### Start From Your Agent -Restart your Agent host, then start from a session opened on your project: +After the [two-step setup](#quick-start), use the entry for your host in a +session opened on your project: -| Host | Type | +| Host | Start and confirm continuation | | --- | --- | -| Codex App, Codex CLI | `$loopx ` | -| Claude Code | `/loopx ` | +| Codex App | `$loopx `; confirm the [heartbeat automation is saved](docs/guides/first-bugfix-loop.md#confirm-that-it-actually-started). | +| Codex CLI | `$loopx `; activate the generated [native `/goal`](docs/product/runtimes/codex-cli/codex-cli-packaged-install.md#current-user-path) in the same session. | +| Claude Code | `/loopx `; follow the [loop driver setup](docs/guides/newcomer-command-path.md#choose-the-loop-driver) for recurring work. | | DeepSeek Harness | Install the [native DSH plugin](packages/dsh-loopx-plugin/README.md), select the `loopx` skill, then describe the task | -For example, give your Agent a concrete outcome: - -```text -$loopx speed up this project's slowest workflow; compare approaches and verify the gains -``` - LoopX reuses existing project state or connects the project, plans the work as -Todos and sets up the host's loop. `loopx status` shows the goal, any decision -waiting for you and the next Todo; `loopx doctor` diagnoses the install. +Todos and guides host activation. Ask your Agent to confirm the active Goal, +next Todo and continuation mechanism; a setup packet alone is not an active loop. +Keep the host available for continued work. `loopx status` shows the Goal, +decisions waiting for you and the next Todo; `loopx doctor` diagnoses the install. -Requires Python 3.11+ and Node.js 22.22.3+ (24 LTS recommended). -[Installing LoopX](docs/guides/installing-loopx.md) covers native Windows, -`pipx`, upgrades, rollback and uninstall. +[Installing LoopX](docs/guides/installing-loopx.md) covers native Windows, `pipx`, +upgrades, rollback and uninstall. **Upgrading from 1.x?** Read the [2.0 migration and recovery guide](docs/product/releases/v2.0.0.md) before continuing existing Goals. diff --git a/README.zh-CN.md b/README.zh-CN.md index f3b33c9e43..9e0ef48f24 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -23,19 +23,22 @@ ## 两步开始 -准备好 Python 3.11+、Node.js 22.22.3+(推荐 24 LTS),安装一次: +**1. 安装 LoopX 和 Agent 技能。** 需要 Python 3.11+、Node.js 22.22.3+(推荐 24 LTS)。 ```bash python3 -m pip install --upgrade loopx && loopx workflow-skills --install ``` -重启 Agent 宿主,打开项目,再给 Codex 一个具体任务: +[其他安装方式与升级说明](docs/guides/installing-loopx.md) + +**2. 给 Agent 一个具体任务。** 重启 Agent 宿主,打开项目,再在 Codex 对话中输入: ```text $loopx 优化这个项目最慢的流程,对比方案并验证性能提升 ``` -Claude Code 使用 `/loopx <复杂任务>`。[其他宿主、安装方式与升级说明 ↓](#试用-loopx) +Claude Code 使用 `/loopx <复杂任务>`;其他运行时见[宿主接入](#从你已经在用的-agent-启动)。 +想跟着教程试一次?[选择一个入门任务](#试用-loopx)。 ## 📰 最新动态 @@ -72,7 +75,7 @@ Claude Code 使用 `/loopx <复杂任务>`。[其他宿主、安装方式与升 | 你想做什么 | 从这里开始 | | --- | --- | -| 让编程或研究 Agent 跨会话持续干活 | [安装并开始](#试用-loopx) | +| 让编程或研究 Agent 跨会话持续干活 | [安装并开始](#快速开始) | | 在一个入口管理个人项目、定时任务与待决事项 | [个人 Agent 工作区](#认识个人-agent-工作区) | | 让多个 Agent 分工协作,交付可验收的结果 | [Agent 协作指南](docs/product/use-cases/cross-runtime/README.md#中文指南) | @@ -178,33 +181,31 @@ App 为 ad-hoc 签名,尚未 notarize。Windows 预览版目前手动更新, ## 试用 LoopX -先安装一次: +选一个能检查实际成果的教程: -```bash -python3 -m pip install --upgrade loopx && loopx workflow-skills --install -``` +| 从哪里开始 | 你会完成什么 | +| --- | --- | +| [查 bug 入门教程](docs/guides/first-bugfix-loop.zh-CN.md) | 在小型 Python 项目中复现两个 bug,让 Agent 修复,再检查测试和补丁。 | +| [长程编程任务指南(English)](docs/guides/long-running-coding-agents.md) | 修复 issue 或持续改进自己的仓库,保留评审、证据和跨会话恢复。 | +| [图文工作区手册](docs/guides/personal-workspace-user-guide.md) | 跟着截图在 App 中管理项目、对话、任务和交付结果。 | ### 从你已经在用的 Agent 启动 -重启 Agent 宿主,在打开了你项目的会话里开始: +完成上面的[两步启动](#快速开始)后,在打开了你项目的会话里使用对应入口: -| 宿主 | 输入 | +| 宿主 | 启动并确认续跑 | | --- | --- | -| Codex App、Codex CLI | `$loopx <复杂任务>` | -| Claude Code | `/loopx <复杂任务>` | +| Codex App | `$loopx <复杂任务>`;确认 [heartbeat 自动化已保存](docs/guides/first-bugfix-loop.zh-CN.md#确认它真的开始执行)。 | +| Codex CLI | `$loopx <复杂任务>`;在同一会话中激活生成的[原生 `/goal`](docs/product/runtimes/codex-cli/codex-cli-packaged-install.md#current-user-path)。 | +| Claude Code | `/loopx <复杂任务>`;按 [loop driver 接入说明](docs/guides/newcomer-command-path.md#choose-the-loop-driver)设置周期性续跑。 | | DeepSeek Harness | 安装 [DSH 原生 Plugin](packages/dsh-loopx-plugin/README.md),在技能选择器中选 `loopx`,然后直接描述任务 | -例如,给 Agent 一个具体成果要求: - -```text -$loopx 优化这个项目最慢的流程,对比方案并验证性能提升 -``` - -LoopX 会复用项目已有状态或连接项目,把工作规划成 Todo,并设置宿主的循环。 -`loopx status` 显示目标、等你处理的决策和下一条 Todo;`loopx doctor` 诊断安装。 +LoopX 会复用项目已有状态或连接项目,把工作规划成 Todo,并引导宿主激活。 +让 Agent 回报当前 Goal、下一条 Todo 和实际续跑机制;生成 setup packet 不等于循环已经启动。 +持续工作需要宿主保持可用。`loopx status` 显示目标、等你处理的决策和下一条 Todo; +`loopx doctor` 诊断安装。 -需要 Python 3.11+ 与 Node.js 22.22.3+(推荐 24 LTS)。原生 Windows、`pipx`、 -升级、回滚与卸载见 [Installing LoopX](docs/guides/installing-loopx.md)。 +原生 Windows、`pipx`、升级、回滚与卸载见[安装指南](docs/guides/installing-loopx.md)。 **从 1.x 升级?** 继续既有 Goal 前,先读 [2.0 迁移与恢复指南](docs/product/releases/v2.0.0.md#中文指南)。 diff --git a/apps/presentation/dashboard/smoke/delegation-search-browser-smoke.mjs b/apps/presentation/dashboard/smoke/delegation-search-browser-smoke.mjs index 972a4d76db..3628865771 100644 --- a/apps/presentation/dashboard/smoke/delegation-search-browser-smoke.mjs +++ b/apps/presentation/dashboard/smoke/delegation-search-browser-smoke.mjs @@ -86,6 +86,22 @@ try { await page.screenshot({path: resolve(output, 'read-failure.png')}); await fixture('recover'); await dialog.getByRole('button', {name: '重试', exact: true}).click(); await settled(); await rows(4); + await fixture('local-source-unavailable'); + const denied = await fetch(`${ready.origin}/api/chat/request-inventory?session_id=${ready.session_id}`); + assert.equal(denied.status, 400); + assert.equal((await denied.json()).error_code, 'request_inventory_unavailable'); + await dialog.getByRole('button', {name: '刷新委派', exact: true}).click(); await settled(); + await dialog.getByRole('alert').waitFor(); await rows(0); + assert.equal(await trigger.getAttribute('aria-label'), '委派,— 条待跟进'); + assert.equal(await dialog.locator('.conversation-requests-pagination').count(), 0); + // Changing the filter must not turn the same missing source into a new + // empty workspace, nor may an unsuccessful retry lose that distinction. + await search('builder'); await settled(); await dialog.getByRole('alert').waitFor(); + await dialog.getByRole('button', {name: '重试', exact: true}).click(); await settled(); + await dialog.getByRole('alert').waitFor(); await rows(0); + await fixture('local-source-recover'); + await dialog.getByRole('button', {name: '重试', exact: true}).click(); await settled(); await rows(9); + await search('reviewer'); await rows(4); assert.equal((await fixture('inspect')).source_unchanged, true); await fixture('deliver'); await search('复核迁移说明中的示例'); await settled(); await dialog.getByText('未找到匹配的委派。', {exact: true}).waitFor(); @@ -126,9 +142,20 @@ try { await fixture('source-recover'); await page.getByRole('button', {name: 'Retry history', exact: true}).click(); await page.getByRole('button', {name: /Delegations,.*awaiting follow-up/}).click(); await page.getByRole('searchbox', {name: 'Search delegations'}).waitFor(); + const english = page.getByRole('dialog', {name: 'Delegations', exact: true}); + await english.getByRole('button', {name: 'All records', exact: true}).click(); await rows(12); + // A real return changes ordering and the snapshot while More is reading. + // Rebuild from the beginning, but fulfill this click's requested extent; + // the parent message refresh must not replace it with the old page count. + await fixture('report-window'); + const nextPage = english.getByRole('button', {name: 'More records', exact: true}); + await nextPage.focus(); await page.keyboard.press('Enter'); await rows(13); + assert.equal(await english.getByRole('button', {name: 'More records', exact: true}).count(), 0); + assert.equal(await page.evaluate(() => document.activeElement?.textContent), '13 / 13 shown'); + await english.getByRole('button', {name: 'Awaiting follow-up', exact: true}).click(); await rows(11); assert.equal(writes.some(path => /request-(?:inventory|control)|team-work/.test(path)), false); assert.deepEqual(errors, []); - console.log('PASS: packaged search, pre-pagination match, count, original conversation, clear/paging, failure/retry, returned history, stale response, Escape/focus, workspace isolation, narrow layout, English, source refusal/recovery and read-only receipts.'); + console.log('PASS: packaged search, pre-pagination match, count, original conversation, clear/paging, failure/retry, returned history, stale response, Escape/focus, workspace isolation, narrow layout, English, source refusal/recovery, changed-snapshot More and read-only receipts.'); } } catch (error) { if (page) {await page.screenshot({path: resolve(output, 'failure.png')}).catch(() => {}); await writeFile(resolve(output, 'failure.txt'), await page.locator('body').innerText());} diff --git a/apps/presentation/dashboard/src/data/conversation-requests.ts b/apps/presentation/dashboard/src/data/conversation-requests.ts index 2c58144b16..faac0c7dac 100644 --- a/apps/presentation/dashboard/src/data/conversation-requests.ts +++ b/apps/presentation/dashboard/src/data/conversation-requests.ts @@ -21,8 +21,9 @@ const inventorySchema = z.object({ok: z.literal(true), session_id: z.string(), r export type ConversationRequest = z.infer; export type ConversationRequestInventory = z.infer; -export async function fetchConversationRequests(sessionId: string, history: boolean, offset = 0, signal?: AbortSignal, search = "") { +export async function fetchConversationRequests(sessionId: string, history: boolean, offset = 0, signal?: AbortSignal, search = "", limit = 12) { const query = new URLSearchParams({session_id: sessionId, view: history ? "history" : "unfinished", offset: String(offset), search}); + if (limit !== 12) query.set("limit", String(limit)); const result = inventorySchema.parse(await requestJson(`/api/chat/request-inventory?${query}`, {signal})); if (result.session_id !== sessionId) throw new Error("Conversation request scope changed."); return result; diff --git a/apps/presentation/dashboard/src/features/personal-workspace/conversation-requests.tsx b/apps/presentation/dashboard/src/features/personal-workspace/conversation-requests.tsx index 5e15d62ffa..c11a76bc9c 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/conversation-requests.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/conversation-requests.tsx @@ -207,6 +207,7 @@ export function ConversationRequests({sessionId, ownerFallback, refreshKey}: {se const [originalRevision, setOriginalRevision] = useState(0); const loadedScope = useRef(null); const loadedSource = useRef(null); + const requestedRows = useRef(12); const generation = useRef(0); const dialog = useRef(null); const trigger = useRef(null); @@ -216,7 +217,9 @@ export function ConversationRequests({sessionId, ownerFallback, refreshKey}: {se const restorePagingFocus = useRef(false); useEffect(() => { setOpen(false); setHistory(false); setPages([]); setError(false); setSearch(""); setSearchDraft(""); - }, [sessionId]); + loadedScope.current = null; loadedSource.current = null; + requestedRows.current = 12; + }, [sessionId, ownerFallback]); useEffect(() => { if (open) dialog.current?.showModal(); else dialog.current?.close(); }, [open]); @@ -231,10 +234,15 @@ export function ConversationRequests({sessionId, ownerFallback, refreshKey}: {se setBusy(true); setError(false); setNoSession(false); const scope = JSON.stringify([sessionId, ownerFallback, history, search]); const sameScope = loadedScope.current === scope; - const pageCount = sameScope ? Math.max(1, pages.length) : 1; + const loadedRows = pages.reduce((count, page) => count + page.rows.length, 0); + // A fresh snapshot or parent refresh must retain the page requested by + // More, including while that read has not published its rows yet. + requestedRows.current = sameScope + ? Math.max(requestedRows.current, offset ? offset + 12 : Math.ceil(loadedRows / 12) * 12) : 12; + const refreshRows = requestedRows.current; // Keep verified rows during a same-source refresh, but never across scope // changes or a failed read. Publish refreshed pages together below. - if (!sameScope) {setPages([]); loadedSource.current = null;} + if (!sameScope) setPages([]); loadedScope.current = scope; try { let source = sessionId && sessionId !== "new-session-pending" ? sessionId : undefined; @@ -243,27 +251,34 @@ export function ConversationRequests({sessionId, ownerFallback, refreshKey}: {se source = listed.sessions.find(row => row.channel_id === "manager")?.session_id; } if (current !== generation.current) return; - if (!source) {setPages([]); setNoSession(true); return;} + if (!source) { + // A vanished source is a failed read, not a new empty workspace. Keep + // its lineage across filters so retry continues to report the loss. + if (loadedSource.current) throw new Error("Previous steward source is unavailable."); + setPages([]); setNoSession(true); return; + } const sameSource = loadedSource.current === source; - if (!sameSource) setPages([]); + if (!sameSource) {setPages([]); requestedRows.current = 12;} loadedSource.current = source; const refreshed: ConversationRequestInventory[] = []; let cursor: number | null = offset; - const limit = offset ? 1 : sameSource ? pageCount : 1; - for (let index = 0; index < limit && cursor !== null; index++) { - const value = await fetchConversationRequests(source, history, cursor, abort.signal, search); + const targetRows = offset ? 12 : sameSource ? refreshRows : 12; + // Each HTTP read verifies the whole inventory. Restore the loaded extent + // in bounded windows; opening and More still read one normal page. + for (let remaining = targetRows; remaining > 0 && cursor !== null; remaining -= 48) { + const value = await fetchConversationRequests(source, history, cursor, abort.signal, search, Math.min(48, remaining)); if (current !== generation.current) return; // Returns can reorder a live inventory. A stale More cursor gets one // fresh read; mixed snapshots during that read fail visibly for retry. if (offset && value.snapshot_digest !== pages[0]?.snapshot_digest) {void load(); return;} - if (index && value.snapshot_digest !== refreshed[0].snapshot_digest) throw new Error("Request snapshot changed."); + if (refreshed.length && value.snapshot_digest !== refreshed[0].snapshot_digest) throw new Error("Request snapshot changed."); if (value.next_offset !== null && value.next_offset <= cursor) throw new Error("Request paging did not advance."); refreshed.push(value); cursor = value.next_offset; } setPages(previous => offset ? [...previous, ...refreshed] : refreshed); if (!offset) setOriginalRevision(value => value + 1); } catch { - if (current === generation.current && !abort.signal.aborted) {setError(true); setPages([]);} + if (current === generation.current && !abort.signal.aborted) {requestedRows.current = 12; setError(true); setPages([]);} } finally {if (current === generation.current) setBusy(false);} } const page = pages[0], last = pages.at(-1); @@ -283,10 +298,11 @@ export function ConversationRequests({sessionId, ownerFallback, refreshKey}: {se const applySearch = (value: string) => { // Fence immediately, including the interval before the effect starts. generation.current++; requestAbort.current?.abort(); + requestedRows.current = 12; setPages([]); setError(false); setBusy(true); setSearch(value.trim()); setRevision(value => value + 1); }; - const close = () => {generation.current++; requestAbort.current?.abort(); setBusy(false); setOpen(false); trigger.current?.focus();}; + const close = () => {generation.current++; requestAbort.current?.abort(); requestedRows.current = 12; setBusy(false); setOpen(false); trigger.current?.focus();}; return