Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion .scratch/durable-multiview/HANDOFF.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Handoff:Pi Durable 多端同屏 spike

- 日期:2026-10-02
- 状态:P0 完成(见 §10),下一步 P1。前置的 Pi 1.0 升级 [PR #425](https://github.com/bubbleptr/pace/pull/425) 已合入,分支 `feat/durable-multiview-spike` 从 `origin/main` 开出。
- 状态:P0、P1 完成(见 §10、§11),下一步 P2(Web UI)。PR 栈:P0 [#426](https://github.com/bubbleptr/pace/pull/426) ← P1 `feat/durable-multiview-tui`。前置的 Pi 1.0 升级 [PR #425](https://github.com/bubbleptr/pace/pull/425) 已合入,分支 `feat/durable-multiview-spike` 从 `origin/main` 开出。
- 关联:[docs/research/pi-durable-analysis.md](../../docs/research/pi-durable-analysis.md)(框架分析,用户的写作素材,只在本地工作区,不提交)、[ADR-0040](../../docs/adr/0040-root-session-process-isolation.md)、[ADR-0042](../../docs/adr/0042-pace-as-chord-presentation-host.md)、[ADR-0044](../../docs/adr/0044-single-writer-session-projection-in-renderer.md)
- 目的:把上一个会话里已核实的事实、已定的决策和待决问题交给新会话,避免重查。

Expand Down Expand Up @@ -185,3 +185,25 @@ Pace 页面放在 `apps/desktop/src/pages/` 下的 dev-only 路由,backend 侧
- **token 要持久化**(数据目录下 `token`,0600)。如果每次启动都重新生成,`kill -9` 重启后客户端就连不上了。鉴权失败时用关闭码 4401,这样浏览器也能读到原因(浏览器 WebSocket 拿不到 HTTP 状态码)。
- **`submit` 带 `requestId`**(客户端生成 UUID),为「回复丢了再重发不重复提交」做准备;目前客户端还不会自动重发。
- §3.1 里与 P4 相关的事实(`TaskOptions.background`、`HookApi` 的能力)本阶段没有复核,P4 用到时再核。

## 11. P1 进展(2026-10-02):TUI 远程客户端

### 11.1 落地的内容

- `tui/tui.ts`:上游 `tui.ts`(earendil-works/pi@7fbbd5f)的移植。保留上游的 tab 缩进,和上游 `diff` 时只显示真正的改动:导入来源、本地的 `agentOf`,以及连接状态提示(`reconnecting` / `closed`)。
- `tui/pi-internals.ts`:**§8.3 的决定是按路径引用 dist,不拷贝。** 用相对路径 `../node_modules/@earendil-works/pi-coding-agent/dist/...` 引入,Node 和 TypeScript 都不会按 `exports` 检查相对路径;它经过的是和包根同一个符号链接,所以 `theme` 这类有状态的模块仍是单实例(已验证 `initTheme` 是同一个函数)。Pi 升级后路径变了,会在 typecheck 阶段报错。
- 比交接文档 §3.2 的表多出两个需要走路径引入的符号:`theme`(根入口只导出了 `Theme` 类型和函数),以及 `KeybindingsManager`(根入口只以 `export type` 导出,值要从 `dist/core/keybindings.js` 拿)。
- `tui/main.ts`:连上宿主后先 `toggleTasks()`,和上游一样默认打开任务面板。主题、快捷键、终端能力用本机的 `SettingsManager`,会话内容来自宿主。
- `cli/host-address.ts`:观察器和 TUI 共用的 `--url` / `--token` / `--data-dir` 解析。

### 11.2 验收

- `test/controller.test.ts`:两个客户端,一端驱动、另一端 steer,两端都能看到队列和 steer 后的那一轮;abort 后片段保留为 aborted 条目;切到另一个会话并对它说话,另一端仍停在 main;切到不存在的会话时给出错误通知,当前会话不变。
- `test/tui.test.ts`:在真实 pty(`@lydell/node-pty`,跑在 vitest 的 Node 上)里启动 TUI,输入的内容能被另一个客户端看到,答案渲染在屏幕上;宿主关闭后显示重连提示,Ctrl+D 以 0 退出。
- 手动用 headless xterm 抓屏走了一遍:流式输出、任务面板、steer、`/agents`、Esc 中止、`kill -9` 后重连。崩溃那一幕的画面是:半句话 → `reconnecting...` → 宿主重启后半句变成 `Operation aborted`,同一个 `pi.generation` 任务重新流式输出 → 完整答案。

### 11.3 新发现

- 用测试 API 直接建的 ownerless 会话 `pi.agent` 是空的(没有模型),提交会得到 `no_model`。子代理会话从父会话复制 agent 配置,不受影响。P4 造演示会话时要带上 `agent`。
- 崩溃的代价是重发一次请求:被打断那次的用量也计入 `pi.usage`(页脚从 `↑6 ↓29` 变成 `↑12 ↓58`)。剧本 §6 第 12 步讲用量时可以顺带提。
- `/agents` 现在只有 main,因为宿主还没装子代理扩展(P4)。协议层的会话切换已在测试里覆盖。
8 changes: 5 additions & 3 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

20 changes: 20 additions & 0 deletions spikes/durable-multiview/cli/host-address.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { readFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import { parseArgs } from "node:util";
import { getAgentDir } from "@earendil-works/pi-coding-agent";

export const DEFAULT_DATA_DIR = join(getAgentDir(), "experimental", "durable-multiview", "default");

/** A client's `--url`, and `--token` or the token file a host on the same machine left in `--data-dir`. */
export async function hostAddress(argv: readonly string[]): Promise<{ url: string; token: string }> {
const { values } = parseArgs({
args: [...argv],
options: {
"data-dir": { type: "string" },
url: { type: "string", default: "ws://127.0.0.1:7420" },
token: { type: "string" },
},
});
const token = values.token ?? (await readFile(join(resolve(values["data-dir"] ?? DEFAULT_DATA_DIR), "token"), "utf8")).trim();
return { url: values.url, token };
}
22 changes: 5 additions & 17 deletions spikes/durable-multiview/cli/observe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,28 +2,16 @@
// A line-mode client of a running host: prints the shown conversation as it
// streams, and sends each typed line. Run several to watch one host from many terminals.
//
// node cli/observe.ts [--data-dir DIR] [--url ws://127.0.0.1:7420]
// node cli/observe.ts [--data-dir DIR | --token T] [--url ws://127.0.0.1:7420]
// typed lines: text (prompt; follow-up while busy), /steer text, /abort, /tasks, /quit
import { readFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import { createInterface } from "node:readline";
import { parseArgs } from "node:util";
import { getAgentDir } from "@earendil-works/pi-coding-agent";
import { connectRemoteDurable } from "../protocol/remote-durable.ts";
import type { DurableView } from "../protocol/view.ts";
import { hostAddress } from "./host-address.ts";
import { initialPrinter, printIncrement } from "./printer.ts";

const { values } = parseArgs({
options: {
"data-dir": { type: "string" },
url: { type: "string", default: "ws://127.0.0.1:7420" },
token: { type: "string" },
},
});
const dataDir = resolve(values["data-dir"] ?? join(getAgentDir(), "experimental", "durable-multiview", "default"));
const token = values.token ?? (await readFile(join(dataDir, "token"), "utf8")).trim();

const remote = await connectRemoteDurable({ url: values.url, token });
const address = await hostAddress(process.argv.slice(2));
const remote = await connectRemoteDurable(address);
let printer = initialPrinter;
let shown: DurableView | undefined;
const render = (): void => {
Expand All @@ -42,7 +30,7 @@ const render = (): void => {
process.stdout.write(step.output);
};
remote.view.subscribe(render);
process.stdout.write(`[connected to ${values.url}, session ${remote.view.current().session.id}]\n`);
process.stdout.write(`[connected to ${address.url}, session ${remote.view.current().session.id}]\n`);
render();

const input = createInterface({ input: process.stdin });
Expand Down
9 changes: 6 additions & 3 deletions spikes/durable-multiview/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
"host": "node host/main.ts",
"observe": "node cli/observe.ts",
"test": "vitest run --config vitest.config.ts",
"typecheck": "tsc --noEmit -p tsconfig.json"
"typecheck": "tsc --noEmit -p tsconfig.json",
"tui": "node tui/main.ts"
},
"dependencies": {
"@earendil-works/chord": "1.0.0",
Expand All @@ -17,10 +18,12 @@
"@earendil-works/pi-durable": "1.0.0",
"proper-lockfile": "4.1.2",
"undici": "8.10.2",
"ws": "8.22.0"
"ws": "8.22.0",
"@earendil-works/pi-tui": "1.0.0"
},
"devDependencies": {
"@types/proper-lockfile": "4.1.4",
"@types/ws": "8.18.2"
"@types/ws": "8.18.2",
"@lydell/node-pty": "^1.2.0-beta.15"
}
}
75 changes: 75 additions & 0 deletions spikes/durable-multiview/test/controller.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import { AgentDoc, type ConversationId } from "@earendil-works/pi-durable";
import { describe, expect, it } from "vitest";
import { isBusy, streamingText, transcript } from "../protocol/transcript.ts";
import { connectTo, LONG_ANSWER, startFauxHost, useCleanups, waitForView } from "./support.ts";

const defer = useCleanups();
const inboxOf = (view: { conversation: { docs: Record<string, unknown> } }) =>
((view.conversation.docs["pi.inbox"] ?? { items: [] }) as { items: { mode: string }[] }).items;

describe("remote controller", () => {
it("steers a busy conversation from another client; both see the queue and the steered turn", async () => {
const host = await startFauxHost(defer, { answers: [LONG_ANSWER, "steered"], tokensPerSecond: 60 });
const driver = await connectTo(defer, host);
const steerer = await connectTo(defer, host);

await driver.controller.submit("investigate", "followUp");
await waitForView(steerer.view, (view) => isBusy(view.conversation));
await steerer.controller.submit("focus on the logs", "steer");
await waitForView(driver.view, (view) => inboxOf(view).some((item) => item.mode === "steer"));

await waitForView(driver.view, (view) => !isBusy(view.conversation) && transcript(view.conversation).at(-1)?.text === "steered");
expect(transcript(driver.view.current().conversation).map((line) => line.text)).toEqual([
"investigate",
LONG_ANSWER,
"focus on the logs",
"steered",
]);
});

it("aborts a streaming answer; the partial stays as an aborted entry", async () => {
const host = await startFauxHost(defer, { tokensPerSecond: 40 });
const client = await connectTo(defer, host);

await client.controller.submit("investigate", "followUp");
await waitForView(client.view, (view) => (streamingText(view.conversation)?.length ?? 0) > 10);
await client.controller.abort();
await waitForView(client.view, (view) => !isBusy(view.conversation));

const [user, answer, ...rest] = transcript(client.view.current().conversation);
expect(user).toEqual({ role: "user", text: "investigate" });
expect(answer).toMatchObject({ role: "assistant", stopReason: "aborted" });
expect(rest).toEqual([]);
});

it("switches to another conversation and talks to it; the other client stays on main", async () => {
const host = await startFauxHost(defer, { answers: ["from the other conversation"] });
const switcher = await connectTo(defer, host);
const bystander = await connectTo(defer, host);
const rootId = switcher.view.current().conversation.conversation.id;
// An ownerless conversation starts with an empty agent; a subagent's would copy its parent's.
const { model } = (await host.harness.snapshot(AgentDoc, rootId, BACKGROUND_CONTEXT))!;
const other = await host.harness.createConversation({ ownership: { kind: "ownerless" }, agent: { model: model! } }, BACKGROUND_CONTEXT);

await waitForView(switcher.view, (view) => view.conversations.some((summary) => summary.id === other.id));
await switcher.controller.switchConversation(other.id);
expect(switcher.view.current().conversation.conversation.id).toBe(other.id);

await switcher.controller.submit("hello other", "followUp");
await waitForView(switcher.view, (view) => transcript(view.conversation).at(-1)?.text === "from the other conversation");
expect(bystander.view.current().conversation.conversation.id).toBe(rootId);
expect(transcript(bystander.view.current().conversation)).toEqual([]);
});

it("reports a switch to an unknown conversation and keeps the current one", async () => {
const host = await startFauxHost(defer);
const client = await connectTo(defer, host);
const rootId = client.view.current().conversation.conversation.id;

await client.controller.switchConversation(999 as ConversationId);

expect(client.view.current().conversation.conversation.id).toBe(rootId);
expect(client.view.current().notices.at(-1)).toMatchObject({ level: "error", message: expect.stringMatching(/999/) });
});
});
54 changes: 11 additions & 43 deletions spikes/durable-multiview/test/gateway.test.ts
Original file line number Diff line number Diff line change
@@ -1,47 +1,15 @@
import { createModels } from "@earendil-works/pi-ai/models";
import { fauxAssistantMessage, fauxProvider } from "@earendil-works/pi-ai/providers/faux";
import { afterEach, describe, expect, it } from "vitest";
import { openHost, type OpenedHost } from "../host/host.ts";
import { connectRemoteDurable, type RemoteDurable } from "../protocol/remote-durable.ts";
import { describe, expect, it } from "vitest";
import type { RemoteDurable } from "../protocol/remote-durable.ts";
import { streamingText, transcript } from "../protocol/transcript.ts";
import { LONG_ANSWER, tempDir, waitForView } from "./support.ts";
import { connectTo, LONG_ANSWER, startFauxHost, useCleanups, waitForView } from "./support.ts";

const cleanups: (() => Promise<void> | void)[] = [];
afterEach(async () => {
for (const cleanup of cleanups.splice(0).reverse()) await cleanup();
});

async function startFauxHost(): Promise<OpenedHost> {
const dir = await tempDir();
cleanups.push(dir.remove);
const faux = fauxProvider({ tokensPerSecond: 200 });
const models = createModels();
models.setProvider(faux.provider);
faux.setResponses([fauxAssistantMessage(LONG_ANSWER)]);
const model = faux.getModel();
const host = await openHost({
dataDir: dir.path,
cwd: dir.path,
models,
modelSummaries: () => [{ provider: model.provider, modelId: model.id, name: model.name, contextWindow: model.contextWindow }],
initialModel: { provider: model.provider, modelId: model.id },
port: 0,
});
cleanups.push(() => host.close());
return host;
}

async function connect(host: OpenedHost, token = host.token): Promise<RemoteDurable> {
const client = await connectRemoteDurable({ url: host.url, token });
cleanups.push(() => client.close());
return client;
}
const defer = useCleanups();

describe("gateway", () => {
it("shows one streamed answer to two clients, driven by either", async () => {
const host = await startFauxHost();
const a = await connect(host);
const b = await connect(host);
const host = await startFauxHost(defer);
const a = await connectTo(defer, host);
const b = await connectTo(defer, host);
expect(b.view.current().conversation.conversation.id).toBe(a.view.current().conversation.conversation.id);

const partialsSeenByB: string[] = [];
Expand All @@ -67,13 +35,13 @@ describe("gateway", () => {
});

it("rejects a client with the wrong token", async () => {
const host = await startFauxHost();
await expect(connect(host, "not-the-token")).rejects.toThrow(/unauthorized/i);
const host = await startFauxHost(defer);
await expect(connectTo(defer, host, "not-the-token")).rejects.toThrow(/unauthorized/i);
});

it("lists the conversations and opens the task graph on request", async () => {
const host = await startFauxHost();
const client = await connect(host);
const host = await startFauxHost(defer);
const client = await connectTo(defer, host);
const rootId = client.view.current().conversation.conversation.id;
expect(client.view.current().conversations.map((summary) => summary.id)).toEqual([rootId]);

Expand Down
Loading
Loading