first commit

This commit is contained in:
魏天炜
2026-11-12 15:29:12 +08:00
commit 5dabea54ab
93 changed files with 12170 additions and 0 deletions

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,620 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="用一段可暂停的动画,看云端 Agent Loop 如何通过本机 daemon 读仓库,以及 Mac 离线后两种架构的区别。">
<title>云端 Agent Loop 怎么读本机仓库?</title>
<style>
:root {
color-scheme: light;
--paper: #f3f5f1;
--ink: #172b36;
--muted: #445a62;
--line: #cbd7d4;
--screen: #0c222c;
--cloud: #a5d2ff;
--local: #a2e6be;
--paused: #ffd29a;
--light: #f4f9fc;
--subtle: #c0d0d6;
--mono: "IBM Plex Mono", ui-monospace, Menlo, monospace;
}
* { box-sizing: border-box; }
[hidden] { display: none !important; }
body {
margin: 0;
background: var(--paper);
color: var(--ink);
font: 16px/1.62 "Inter", "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
}
h1, h2, h3, p, pre, dl, dd { margin: 0; }
h1, h2, h3 { line-height: 1.22; }
h1 { font-size: clamp(2rem, 5.6vw, 4.05rem); letter-spacing: -.048em; text-wrap: balance; }
h1 span { white-space: nowrap; }
h2 { font-size: clamp(1.45rem, 3.2vw, 2.05rem); letter-spacing: -.025em; }
h3 { font-size: 1.05rem; }
a { color: #165984; text-underline-offset: .2em; }
button, input { font: inherit; }
button { cursor: pointer; }
button:disabled { cursor: default; opacity: .45; }
:focus-visible { outline: 3px solid #3787bc; outline-offset: 3px; }
.wrap { width: min(100% - 2rem, 1100px); margin-inline: auto; }
.sr-only {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.heading { padding-block: clamp(1.7rem, 4.4vw, 3.35rem) 1.1rem; }
.heading p { margin-top: .6rem; color: var(--muted); }
.player { border-radius: 14px; overflow: clip; background: var(--screen); color: var(--light); }
.controls {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: .65rem .9rem;
padding: .7rem clamp(.7rem, 2.3vw, 1.2rem);
border-bottom: 1px solid #35505b;
}
.play {
min-width: 6rem;
min-height: 44px;
padding: .45rem .65rem;
border: 0;
border-radius: 7px;
background: var(--cloud);
color: var(--screen);
font-weight: 750;
}
.seek {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: .55rem;
}
.seek input { width: 100%; min-width: 0; accent-color: var(--cloud); cursor: pointer; }
.seek output { font: .75rem var(--mono); white-space: nowrap; color: var(--subtle); }
.chapters {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: .45rem;
padding: .65rem clamp(.7rem, 2.3vw, 1.2rem);
}
.chapters button {
min-height: 44px;
padding: .35rem .4rem;
border: 1px solid #35505b;
border-radius: 7px;
background: #122e3a;
color: var(--subtle);
font-size: .86rem;
font-weight: 650;
}
.chapters button[aria-pressed="true"] { border-color: var(--cloud); color: var(--cloud); }
.chapters button:hover { background: #1d3a48; }
.frame { padding: 0 clamp(.7rem, 2.3vw, 1.2rem) .7rem; }
.diagram { position: relative; display: grid; gap: .1rem; }
.wire { position: absolute; z-index: 1; inset: 0; width: 100%; height: 100%; pointer-events: none; }
.zone { padding: .7rem clamp(.45rem, 2.1vw, 1.15rem) .75rem; border: 1px solid #2e4b58; border-radius: 9px; background: #112d3a; }
.zone.mac { background: #112f2c; border-color: #30544b; }
.zone-label { margin-bottom: .48rem; color: var(--cloud); font-size: .79rem; font-weight: 700; }
.mac .zone-label { color: var(--local); }
.actors { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: clamp(.65rem, 5vw, 4.65rem); }
.actor {
z-index: 2;
min-width: 0;
min-height: 76px;
display: flex;
flex-direction: column;
justify-content: center;
gap: .17rem;
padding: .4rem clamp(.3rem, 1.5vw, .8rem);
border: 1px solid #466372;
border-radius: 7px;
background: #173544;
overflow-wrap: anywhere;
}
.mac .actor { background: #193a35; border-color: #41665a; }
.actor .name { font-size: clamp(.78rem, 2.15vw, 1.08rem); font-weight: 750; line-height: 1.28; }
.actor .state { color: var(--subtle); font-size: clamp(.69rem, 1.5vw, .87rem); line-height: 1.35; }
.actor[data-active] { border-color: var(--cloud); box-shadow: inset 0 0 0 1px var(--cloud); }
.mac .actor[data-active] { border-color: var(--local); box-shadow: inset 0 0 0 1px var(--local); }
.actor[data-active="receiver"] { background: #234b5e; }
.mac .actor[data-active="receiver"] { background: #245446; }
.channel {
position: relative;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
align-items: center;
min-height: 48px;
color: var(--subtle);
font-size: .75rem;
}
.channel::before { content: ""; position: absolute; inset-inline: 0; top: 50%; border-top: 1px dashed #57707b; }
.channel span { position: relative; z-index: 2; grid-column: 3; justify-self: end; padding-inline: .4rem; background: var(--screen); text-align: right; }
.caption {
display: grid;
gap: .55rem;
align-items: start;
padding: .9rem clamp(.7rem, 2.3vw, 1.2rem) 1.05rem;
border-top: 1px solid #35505b;
background: #112c39;
}
.caption h2 { font-size: clamp(1.18rem, 2.5vw, 1.65rem); }
.caption p { margin-top: .3rem; color: var(--subtle); font-size: .94rem; }
.payload {
padding: .55rem .65rem;
border: 1px solid #436471;
border-radius: 7px;
color: var(--cloud);
background: var(--screen);
font: .82rem/1.55 var(--mono);
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.payload[data-tone="local"] { color: var(--local); }
.payload[data-tone="wait"] { color: var(--paused); }
.comparison {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: clamp(.45rem, 2.3vw, 1rem);
}
.side { display: grid; align-content: start; gap: .55rem; min-width: 0; padding: .65rem; border: 1px solid #3f5864; border-radius: 9px; background: #112d3a; }
.side h3 { min-height: 2.55em; font-size: clamp(.9rem, 2.4vw, 1.1rem); }
.compare-state { padding: .5rem; min-height: 3rem; border-radius: 6px; background: #193848; color: var(--light); font-size: clamp(.77rem, 1.65vw, .93rem); line-height: 1.4; }
.compare-state[data-state="off"] { color: var(--paused); background: #473a2c; }
.compare-state[data-state="online"] { color: var(--local); background: #23443b; }
.compare-state[data-state="waiting"] { color: var(--paused); border: 1px dashed #997653; background: #302f2b; }
.compare-state strong { display: block; }
.compare-state span { display: block; margin-top: .12rem; }
.compare-state.reveal { animation: arrive .35s ease-out both; }
@keyframes arrive { from { opacity: .5; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } }
.answers { display: grid; gap: 1.25rem; padding-block: 1.6rem; }
.answers p { margin-top: .5rem; color: var(--muted); }
.answers strong { color: var(--ink); }
.sources { padding-block: .8rem 2rem; border-top: 1px solid var(--line); color: var(--muted); font-size: .84rem; }
.sources a { display: inline-block; margin: .3rem .75rem 0 0; }
@media (min-width: 720px) {
.wrap { width: min(100% - 3.5rem, 1100px); }
.caption { grid-template-columns: minmax(0, 1.3fr) minmax(0, 1fr); gap: 1.2rem; }
.comparison { gap: 1rem; }
.side { padding: 1rem; min-height: 16.7rem; }
.side h3 { min-height: 0; }
.answers { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 395px) {
.wrap { width: calc(100% - 1rem); }
.chapters button { font-size: .75rem; }
.actor { min-height: 70px; }
.controls { gap: .5rem; }
.seek { grid-template-columns: minmax(0, 1fr); gap: 0; }
.seek output { text-align: end; }
}
@media (prefers-reduced-motion: reduce) {
.compare-state.reveal { animation: none; }
}
</style>
</head>
<body>
<main class="wrap">
<header class="heading">
<h1>云端 Agent Loop <span>怎么读本机仓库?</span></h1>
<p>跟着一次读文件请求走。最后让 Mac 合盖,看两种架构分别停在哪里。</p>
</header>
<section class="player" aria-label="Agent Loop 动画">
<div class="controls" id="controls" hidden>
<button class="play" id="play" type="button">▶ 播放</button>
<div class="seek">
<label class="sr-only" for="seek">播放进度</label>
<input id="seek" type="range" min="0" value="0" step="0.1">
<output for="seek" id="clock"></output>
</div>
</div>
<nav class="chapters" id="chapters" aria-label="动画章节" hidden>
<button type="button" data-chapter="0" aria-pressed="true">云端怎么跑</button>
<button type="button" data-chapter="1" aria-pressed="false">怎么读仓库</button>
<button type="button" data-chapter="2" aria-pressed="false">Mac 合盖以后</button>
</nav>
<div class="frame">
<div class="diagram" id="diagram" aria-label="云端与开发机的部署位置">
<div class="zone">
<p class="zone-label">云端</p>
<div class="actors">
<div class="actor" id="model"><span class="name">模型 API</span><span class="state">V4 Flash 推理</span></div>
<div class="actor" id="loop"><span class="name">Agent Loop</span><span class="state">组请求、派工具</span></div>
<div class="actor" id="session"><span class="name">Session</span><span class="state">持久化记录</span></div>
</div>
</div>
<div class="channel"><span id="channel">云端 / 本机边界</span></div>
<div class="zone mac">
<p class="zone-label">你的 Mac</p>
<div class="actors">
<div aria-hidden="true"></div>
<div class="actor" id="daemon"><span class="name">执行器 daemon</span><span class="state">接请求、跑工具</span></div>
<div class="actor" id="repo"><span class="name">Git 工作区</span><span class="state">源码和未提交改动</span></div>
</div>
</div>
<canvas class="wire" id="wire" aria-hidden="true"></canvas>
</div>
<div class="comparison" id="comparison" aria-label="Mac 离线后的并排比较" hidden>
<section class="side" aria-labelledby="local-title">
<h3 id="local-title">本地 Loop</h3>
<div class="compare-state" data-state="off" id="local-loop"></div>
<div class="compare-state" data-state="off" id="local-files"></div>
<div class="compare-state" id="local-outcome"></div>
</section>
<section class="side" aria-labelledby="cloud-title">
<h3 id="cloud-title">云端 Loop + 本机执行</h3>
<div class="compare-state" data-state="online" id="cloud-loop"></div>
<div class="compare-state" data-state="off" id="cloud-files"></div>
<div class="compare-state" id="cloud-outcome"></div>
</section>
</div>
</div>
<div class="caption">
<div><h2 id="shot-title">云端 Loop 接到开发需求</h2><p id="shot-text">你要给订单列表加状态筛选。云端进程读回 Session,准备下一次模型请求。</p></div>
<pre class="payload" id="payload">Session → Agent Loop
任务需求 + 已有会话记录</pre>
</div>
<p class="sr-only" id="notice" aria-live="polite"></p>
</section>
<section class="answers" aria-label="两个问题的答案">
<article>
<h2>工具和信道各管什么?</h2>
<p><strong>工具定义要做什么,信道负责把请求送过去。</strong>模型给出 <code>read_file</code> 和路径。云端 Loop 把它封成工具请求,发到这台 Mac 的 daemon。daemon 读本地磁盘,再沿连接把文件内容和调用 ID 回给 Loop。原仓库留在本机,返回的文件内容可以进入云端 Session 和下一次模型请求。</p>
</article>
<article>
<h2>只有这台 Mac,值得搬吗?</h2>
<p><strong>如果所有工作都依赖这台 Mac,而且你通常在开着电脑时才用 Agent,本地 Loop 更直接。</strong>云端能在你关终端或换设备后接收消息、回答已保存的结果、等待 Mac 重连。要在 Mac 离线时继续跑代码,得有另一台在线的执行机,且它得有任务所需的代码版本和环境。</p>
</article>
</section>
<footer class="sources">
<p>动画按 DSH §5 的部署设想演绎。WebSocket 是信道举例。此前实测使用真实 V4 Flash 网关,Loop 和 daemon 则在同一台 Mac 上以独立进程通过 HTTP 通信。动画时间为讲解节奏。</p>
<a href="agent-loop-and-execution.html">查看真实请求、进程号和 Git diff</a>
<a href="https://telegra.ph/Super-agent-%E8%AE%BE%E8%AE%A1%E6%8E%A2%E7%B4%A2%E8%BF%88%E5%90%91%E6%9B%B4%E8%87%AA%E4%B8%BB%E7%9A%84-Agent-%E7%B3%BB%E7%BB%9F-10-31-2">DSH 设计文章 §5(中文镜像)</a>
</footer>
</main>
<script>
(() => {
const shots = [
{
duration: 4.6, chapter: 0, route: ["session", "loop"],
title: "云端 Loop 接到开发需求", arrival: "装载会话",
text: "你要给订单列表加状态筛选。云端调度启动一个 Loop 进程,它读回 Session,准备下一次模型请求。",
payload: "Session → Agent Loop\n任务需求 + 已有会话记录"
},
{
duration: 4.5, chapter: 0, route: ["loop", "model"],
title: "Loop 组请求,模型做推理", arrival: "收到消息和工具定义",
text: "云端 Loop 把需求、会话消息和可用工具的定义发给 V4 Flash。Loop 和模型 API 各自运行。",
payload: "Agent Loop → 模型 API\nmessages + tools"
},
{
duration: 4.6, chapter: 0, route: ["model", "loop"],
title: "模型决定先读一个文件", arrival: "收到 read_file",
text: "V4 Flash 返回结构化的工具调用。此时只有函数名和参数,文件还没被读取。",
payload: "模型 API → Agent Loop\nread_file({ path: \"orders.mjs\" })"
},
{
duration: 5.2, chapter: 1, route: ["daemon", "loop"], link: true, tone: "local",
title: "Mac 先连上设备信道", arrival: "设备已登记",
text: "工具下发前,Mac 上的 daemon 已主动连到云端,并登记设备身份。这里用一条双向 WebSocket 举例。",
payload: "Mac daemon → 云端设备入口\n建立已鉴权的双向连接"
},
{
duration: 5, chapter: 1, route: ["loop", "daemon"], link: true,
title: "Loop 把工具请求发给这台 Mac", arrival: "收到路径和调用 ID",
text: "Loop 根据任务绑定的设备和工作区,经云端设备入口找到连接,把工具名、路径和调用 ID 发给 daemon。",
payload: "Agent Loop → 本机 daemon\n{ id: \"call-1\", tool: \"read_file\", path: \"orders.mjs\" }"
},
{
duration: 4.4, chapter: 1, route: ["daemon", "repo"], link: true, tone: "local",
title: "daemon 在本地磁盘上执行读取", arrival: "读取 orders.mjs",
text: "daemon 校验工作区范围,在 Mac 上打开 orders.mjs。它读的是当前工作区,包括尚未提交的内容。",
payload: "本机 daemon → Git 工作区\nfs.readFile(工作区内的 orders.mjs)"
},
{
duration: 4.8, chapter: 1, route: ["daemon", "loop"], link: true, tone: "local",
title: "文件内容沿同一条信道回云端", arrival: "收到源码",
text: "daemon 带着同一个调用 ID 返回源码。Git 仓库仍在 Mac,读出的文件内容现在也到了云端。",
payload: "本机 daemon → Agent Loop\ncall-1 · export function filterOrders(orders) { … }"
},
{
duration: 4.6, chapter: 1, route: ["loop", "model"], link: true,
title: "Loop 把工具结果放进下一次请求", arrival: "收到 tool_result",
text: "模型看见本机读出的源码,才能决定怎么改。这就完成了一轮「请求模型 → 执行工具 → 把结果给模型」。",
payload: "Agent Loop → 模型 API\nmessages + tool_result(源码)"
},
{
duration: 4.7, chapter: 1, route: ["loop", "daemon"], link: true, repo: "写入后有未提交改动",
title: "写代码和跑测试也走这个信道", arrival: "写入后启动测试",
text: "Loop 下发补丁和测试命令,daemon 在原工作区写入并启动测试。代码改动和工具进程都在 Mac。",
payload: "Agent Loop → 本机 daemon\nwrite_file / start_tests"
},
{
duration: 4.8, chapter: 1, route: ["loop", "session"], link: true, repo: "改动仍未提交",
title: "测试结果回传后,Session 记下结果", arrival: "保存退出码和摘要",
text: "daemon 把测试输出回传给 Loop。云端保存退出码和摘要,以后可以从记录里查到这次测试。",
payload: "Agent Loop → Session\n退出码 0 · 3 个测试通过"
},
{
duration: 5.6, chapter: 2, compare: "closed", tone: "wait",
title: "现在把 Mac 合盖",
text: "两种架构都碰不到这台 Mac 的工作区。本地 Loop 随电脑暂停,云端 Loop 还可以收到新消息。",
payload: "本地工作区离线\n两边都读不到当前文件"
},
{
duration: 6, chapter: 2, compare: "question", tone: "wait",
title: "从手机问:测试过了吗?再看一眼 diff",
text: "云端可以从 Session 回答「3 个已通过」。当前 diff 只能等 Mac 重连。本地 Loop 则要等 Mac 恢复才能处理。",
payload: "查以前的结果 · 云端可以\n读现在的文件 · 等 Mac 重连"
},
{
duration: 5.1, chapter: 2, compare: "resume", tone: "local",
title: "Mac 重连,云端把待办接着做",
text: "工作区恢复可达。云端收到设备上线事件,派发等着的 git diff,把结果回到手机。本地会话也可以恢复工作。",
payload: "设备重连 → 下发待办 → 读取本机 diff"
},
{
duration: 6.7, chapter: 2, compare: "verdict", tone: "local",
title: "只有这台 Mac,收益主要在任务管理",
text: "日常都是开着 Mac 改这一个仓库,本地 Loop 更直接。云端的价值是保持任务入口在线,或把可迁移的工作交给另一台有代码和环境的执行机。",
payload: "单机交互开发 · 本地更简单\n跨设备接续和调度 · 云端有用"
}
];
const byId = id => document.getElementById(id);
const diagram = byId("diagram");
const comparison = byId("comparison");
const canvas = byId("wire");
const ctx = canvas.getContext("2d");
const slider = byId("seek");
const chapters = [...document.querySelectorAll("[data-chapter]")];
const actors = [...document.querySelectorAll(".actor")];
const defaults = Object.fromEntries(actors.map(actor => [actor.id, actor.querySelector(".state").textContent]));
const quietMotion = matchMedia("(prefers-reduced-motion: reduce)");
let duration = 0;
const starts = shots.map(shot => {
const start = duration;
duration = Math.round((duration + shot.duration) * 10) / 10;
return start;
});
const chapterStarts = chapters.map((_, chapter) => starts[shots.findIndex(shot => shot.chapter === chapter)]);
let time = 0;
let shotIndex = -1;
let playing = false;
let frameId = 0;
let lastFrame = 0;
let geometry = null;
let arrived = false;
function compareState(id, title, description, status) {
const element = byId(id);
const label = document.createElement("strong");
const detail = document.createElement("span");
label.textContent = title;
detail.textContent = description;
element.dataset.state = status;
element.replaceChildren(label, detail);
if (!quietMotion.matches && playing) {
element.classList.remove("reveal");
requestAnimationFrame(() => element.classList.add("reveal"));
}
}
function renderComparison(scene) {
const result = scene === "question";
if (scene === "verdict") {
compareState("local-loop", "单机交互开发", "你开着 Mac,Agent 跟着你改代码。", "online");
compareState("local-files", "原工作区", "直接使用本机源码、改动和工具链。", "online");
compareState("local-outcome", "本地 Loop 更直接", "会话和工具都跟着开发机。", "online");
compareState("cloud-loop", "任务入口持续在线", "换到手机,也能接收任务、查询已有结果。", "online");
compareState("cloud-files", "依赖 Mac 的任务", "Mac 离线时等待重连。", "waiting");
compareState("cloud-outcome", "有其他在线执行机", "它有匹配的代码和环境时,可以接手对应任务。", "online");
return;
}
if (scene === "resume") {
compareState("local-loop", "Mac 上的 Loop", "电脑唤醒,可以恢复本地会话", "online");
compareState("local-files", "Mac 工作区", "在线,可以读当前文件", "online");
compareState("local-outcome", "本地操作", "在本地会话发起读取。", "online");
compareState("cloud-loop", "云端 Loop + Session", "收到设备重新上线事件", "online");
compareState("cloud-files", "Mac 工作区", "daemon 执行挂起的 git diff", "online");
compareState("cloud-outcome", "待办已完成", "当前 diff 回到手机。", "online");
return;
}
compareState("local-loop", "Mac 上的 Loop", "电脑休眠,Loop 暂停", "off");
compareState("local-files", "Mac 工作区", "离线,当前文件不可访问", "off");
compareState("local-outcome", "新消息",
result ? "等 Mac 恢复,才能处理测试和 diff 的追问。" : "等 Mac 恢复后处理。", "waiting");
compareState("cloud-loop", "云端 Loop + Session", "在线,可以接消息、查历史", "online");
compareState("cloud-files", "Mac 工作区", "离线,当前文件同样不可访问", "off");
compareState("cloud-outcome", result ? "3 个测试已通过" : "新消息",
result ? "当前 diff:等待 Mac 重连。" : "先接收消息,依赖文件的动作排队。", "waiting");
}
function updateGeometry() {
if (diagram.hidden || !ctx) return;
const area = diagram.getBoundingClientRect();
const scale = window.devicePixelRatio || 1;
canvas.width = Math.round(area.width * scale);
canvas.height = Math.round(area.height * scale);
ctx.setTransform(scale, 0, 0, scale, 0, 0);
const nodes = Object.fromEntries(actors.map(actor => {
const bounds = actor.getBoundingClientRect();
return [actor.id, {
left: bounds.left - area.left, right: bounds.right - area.left,
top: bounds.top - area.top, bottom: bounds.bottom - area.top,
x: (bounds.left + bounds.right) / 2 - area.left,
y: (bounds.top + bounds.bottom) / 2 - area.top
}];
}));
geometry = { width: area.width, height: area.height, nodes };
}
function ports(from, to) {
const a = geometry.nodes[from];
const b = geometry.nodes[to];
if (Math.abs(a.y - b.y) > 30) {
return a.y < b.y ? [[a.x, a.bottom], [b.x, b.top]] : [[a.x, a.top], [b.x, b.bottom]];
}
return a.x < b.x ? [[a.right, a.y], [b.left, b.y]] : [[a.left, a.y], [b.right, b.y]];
}
function line(start, end, color, width, dashed = false) {
ctx.strokeStyle = color;
ctx.lineWidth = width;
ctx.setLineDash(dashed ? [4, 4] : []);
ctx.beginPath();
ctx.moveTo(...start);
ctx.lineTo(...end);
ctx.stroke();
ctx.setLineDash([]);
}
function arrow(tip, angle, color, size) {
ctx.fillStyle = color;
ctx.beginPath();
ctx.moveTo(...tip);
ctx.lineTo(tip[0] - size * Math.cos(angle - .45), tip[1] - size * Math.sin(angle - .45));
ctx.lineTo(tip[0] - size * Math.cos(angle + .45), tip[1] - size * Math.sin(angle + .45));
ctx.closePath();
ctx.fill();
}
function draw(shot, progress) {
if (!geometry || !shot.route || !ctx) return;
ctx.clearRect(0, 0, geometry.width, geometry.height);
if (shot.link) {
const [top, bottom] = ports("loop", "daemon");
line(top, bottom, "#7494a1", 2, true);
}
const [start, end] = ports(...shot.route);
const color = shot.tone === "local" ? "#a2e6be" : "#a5d2ff";
line(start, end, "#587888", 2);
const travel = quietMotion.matches ? 1 : Math.max(.08, Math.min(1, progress / .72));
const x = end[0] - start[0];
const y = end[1] - start[1];
const length = Math.hypot(x, y);
const angle = Math.atan2(y, x);
const tip = [start[0] + x * travel, start[1] + y * travel];
line(start, tip, color, 3);
arrow(tip, angle, color, 8);
if (travel < 1) {
const size = Math.min(20, length * .45);
const offset = Math.max(size / 2, Math.min(length - size / 2, travel * length));
const center = [start[0] + offset * Math.cos(angle), start[1] + offset * Math.sin(angle)];
ctx.fillStyle = color;
ctx.beginPath();
ctx.roundRect(center[0] - size / 2, center[1] - size / 2, size, size, 4);
ctx.fill();
if (size >= 14) {
ctx.fillStyle = "#0c222c";
ctx.font = "700 11px monospace";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText("··", center[0], center[1]);
}
}
const complete = travel >= 1;
if (complete !== arrived) {
arrived = complete;
const receiver = byId(shot.route[1]);
if (complete) receiver.dataset.active = "receiver";
else delete receiver.dataset.active;
const idle = receiver.id === "repo" && shot.repo ? shot.repo : defaults[receiver.id];
receiver.querySelector(".state").textContent = complete ? shot.arrival : idle;
}
}
function activate(index, announce) {
shotIndex = index;
const shot = shots[index];
byId("shot-title").textContent = shot.title;
byId("shot-text").textContent = shot.text;
const payload = byId("payload");
payload.textContent = shot.payload;
payload.dataset.tone = shot.tone || "";
chapters.forEach((button, position) => button.setAttribute("aria-pressed", String(position === shot.chapter)));
diagram.hidden = Boolean(shot.compare);
comparison.hidden = !shot.compare;
if (shot.compare) {
renderComparison(shot.compare);
} else {
byId("channel").textContent = shot.link ? "双向设备信道" : "云端 / 本机边界";
for (const actor of actors) {
delete actor.dataset.active;
actor.querySelector(".state").textContent = actor.id === "repo" && shot.repo ? shot.repo : defaults[actor.id];
}
byId(shot.route[0]).dataset.active = "sender";
arrived = false;
updateGeometry();
}
if (announce) byId("notice").textContent = shot.title;
}
function timestamp(seconds) {
const whole = Math.floor(seconds + .001);
return Math.floor(whole / 60) + ":" + String(whole % 60).padStart(2, "0");
}
function render(position, announce = false) {
time = Math.max(0, Math.min(duration, position));
const index = starts.findLastIndex(start => start <= time);
if (index !== shotIndex) activate(index, announce);
slider.value = time;
byId("clock").textContent = timestamp(time) + " / " + timestamp(duration);
const progress = (time - starts[index]) / shots[index].duration;
draw(shots[index], progress);
}
function pause() {
playing = false;
cancelAnimationFrame(frameId);
byId("play").textContent = time >= duration ? "↻ 重播" : "▶ 播放";
}
function tick(now) {
if (!playing) return;
render(time + (now - lastFrame) / 1000);
lastFrame = now;
if (time >= duration) pause();
else frameId = requestAnimationFrame(tick);
}
byId("play").addEventListener("click", () => {
if (playing) {
pause();
return;
}
if (time >= duration) render(0, true);
playing = true;
byId("play").textContent = "Ⅱ 暂停";
lastFrame = performance.now();
frameId = requestAnimationFrame(tick);
});
slider.addEventListener("input", () => {
render(Number(slider.value), true);
if (playing) lastFrame = performance.now();
else pause();
});
chapters.forEach((button, index) => button.addEventListener("click", () => {
render(chapterStarts[index], true);
if (playing) lastFrame = performance.now();
else pause();
}));
new ResizeObserver(() => {
updateGeometry();
render(time);
}).observe(diagram);
slider.max = duration;
byId("controls").hidden = false;
byId("chapters").hidden = false;
render(0);
})();
</script>
</body>
</html>

View File

@@ -0,0 +1,497 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>计算与状态分离:一次改 bug 的全过程</title>
<style>
:root {
--bg: #f6f7f9;
--card: #ffffff;
--ink: #1f2328;
--muted: #656d76;
--line: #d8dee4;
--compute: #2f6fde;
--compute-bg: #eaf1fd;
--state: #8a4dd6;
--state-bg: #f3ecfc;
--local: #1a7f4b;
--local-bg: #e6f4ec;
--warn: #b35900;
--warn-bg: #fff4e5;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--ink);
font: 16px/1.65 -apple-system, BlinkMacSystemFont, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
}
main { max-width: 1180px; margin: 0 auto; padding: 28px 24px 60px; }
h1 { font-size: 26px; margin: 0 0 6px; }
h2 { font-size: 19px; margin: 36px 0 12px; }
.lead { color: var(--muted); margin: 0 0 4px; }
code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.9em; background: #eef1f4; padding: 1px 5px; border-radius: 4px; }
.note { background: var(--warn-bg); border-left: 4px solid var(--warn); padding: 10px 14px; border-radius: 6px; margin: 14px 0; font-size: 15px; }
/* 三种布局对比 */
.layouts { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; }
.layout { background: var(--card); border: 1px solid var(--line); border-radius: 10px; padding: 14px; }
.layout h3 { font-size: 16px; margin: 0 0 2px; }
.layout .sub { color: var(--muted); font-size: 13px; margin-bottom: 10px; }
.tier { border-radius: 8px; padding: 8px 10px; margin-top: 8px; font-size: 14px; }
.tier b { display: block; font-size: 12px; letter-spacing: .04em; color: var(--muted); font-weight: 600; }
.tier.cloud { background: #eef2f7; }
.tier.mine { background: var(--local-bg); }
.chip { display: inline-block; padding: 1px 8px; margin: 3px 4px 0 0; border-radius: 999px; font-size: 13px; background: #fff; border: 1px solid var(--line); }
.chip.c { border-color: var(--compute); color: var(--compute); }
.chip.s { border-color: var(--state); color: var(--state); }
.chip.l { border-color: var(--local); color: var(--local); }
.chip.x { border-style: dashed; color: var(--muted); }
.layout .verdict { font-size: 14px; margin-top: 10px; color: var(--muted); }
.layout.pick { border: 2px solid var(--ink); }
/* 舞台 */
.stage { background: var(--card); border: 1px solid var(--line); border-radius: 12px; padding: 18px; }
.you {
display: flex; align-items: center; gap: 10px;
border: 1px dashed var(--line); border-radius: 8px; padding: 8px 12px; margin-bottom: 12px; font-size: 15px;
transition: background .25s, border-color .25s;
}
.you.on { background: #fff8d6; border-color: #d4a72c; }
.you .who { font-weight: 600; }
.zones { display: grid; grid-template-columns: 1fr 1.15fr 1.15fr; gap: 12px; }
.zone { border-radius: 10px; padding: 12px 14px; min-height: 300px; border: 2px solid transparent; transition: border-color .25s, box-shadow .25s; }
.zone h3 { margin: 0; font-size: 16px; }
.zone .where { font-size: 12px; color: var(--muted); margin-bottom: 10px; }
.zone.compute { background: var(--compute-bg); }
.zone.state { background: var(--state-bg); }
.zone.local { background: var(--local-bg); }
.zone.on.compute { border-color: var(--compute); box-shadow: 0 0 0 4px rgba(47,111,222,.12); }
.zone.on.state { border-color: var(--state); box-shadow: 0 0 0 4px rgba(138,77,214,.12); }
.zone.on.local { border-color: var(--local); box-shadow: 0 0 0 4px rgba(26,127,75,.12); }
.zone.off { filter: grayscale(1); opacity: .55; }
.item { background: #fff; border-radius: 6px; padding: 6px 9px; margin-bottom: 6px; font-size: 14px; border: 1px solid rgba(0,0,0,.06); }
.item.new { animation: pop .6s ease; outline: 2px solid #d4a72c; }
.item.gone { text-decoration: line-through; color: var(--muted); background: transparent; border-style: dashed; }
.item small { display: block; color: var(--muted); font-size: 12px; }
.empty { color: var(--muted); font-size: 14px; font-style: italic; padding: 6px 0; }
@keyframes pop { from { transform: scale(.96); } to { transform: scale(1); } }
.log { list-style: none; padding: 0; margin: 0; max-height: 640px; overflow-y: auto; }
.log li { display: flex; gap: 8px; font-size: 13.5px; background: #fff; border-radius: 6px; padding: 5px 8px; margin-bottom: 5px; border: 1px solid rgba(0,0,0,.06); }
.log li .n { color: var(--state); font-weight: 600; min-width: 26px; }
.log li .t { color: var(--muted); min-width: 64px; }
.log li.new { outline: 2px solid #d4a72c; }
.flow { margin: 14px 0 0; font-size: 14px; color: var(--muted); }
.flow span { color: var(--ink); font-weight: 600; }
/* 解说区 */
.explain { display: grid; grid-template-columns: 1.6fr 1fr; gap: 14px; margin-top: 14px; }
.box { background: var(--card); border: 1px solid var(--line); border-radius: 10px; padding: 14px 16px; }
.box h4 { margin: 0 0 6px; font-size: 17px; }
.box p { margin: 6px 0; }
.box.cli { background: #fafbfc; }
.box.cli h4 { font-size: 14px; color: var(--muted); font-weight: 600; }
.box.cli p { font-size: 14.5px; }
.cloudline { margin-top: 8px; font-size: 14px; background: #eef2f7; border-radius: 6px; padding: 6px 10px; }
.controls { display: flex; align-items: center; gap: 10px; margin-top: 16px; flex-wrap: wrap; }
button {
font: inherit; font-size: 15px; padding: 7px 16px; border-radius: 8px; border: 1px solid var(--line);
background: #fff; cursor: pointer;
}
button.primary { background: var(--ink); color: #fff; border-color: var(--ink); }
button:disabled { opacity: .4; cursor: default; }
.dots { display: flex; gap: 6px; flex: 1; justify-content: center; }
.dot { width: 26px; height: 26px; border-radius: 50%; border: 1px solid var(--line); background: #fff; font-size: 12px; padding: 0; }
.dot.on { background: var(--ink); color: #fff; border-color: var(--ink); }
.dot.done { background: #d0d7de; }
.hint { font-size: 13px; color: var(--muted); }
.summary { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
.summary .box h4 { font-size: 15px; }
.summary ul { margin: 6px 0 0; padding-left: 18px; font-size: 14.5px; }
.takeaway { margin-top: 16px; font-size: 17px; background: var(--card); border: 2px solid var(--ink); border-radius: 10px; padding: 14px 18px; }
.src { font-size: 13px; color: var(--muted); margin-top: 26px; }
.src a { color: var(--muted); }
@media (max-width: 900px) {
.layouts, .zones, .explain, .summary { grid-template-columns: 1fr; }
.zone { min-height: 0; }
}
</style>
</head>
<body>
<main>
<h1>计算与状态分离:跟着一次改 bug 走一遍</h1>
<p class="lead">需求:<b>“登录令牌在过期的那一秒还能用,修一下,并跑回归测试。”</b></p>
<p class="lead">下面一步步看:哪些东西在云端、云端在做什么、你的电脑上留着什么。</p>
<div class="note">
先澄清一个容易混的点:<b>模型推理本来就在云端</b>(你今天用 Claude Code、Codex 也是调云端模型 API)。这里说的“计算”指的是 <b>Agent Loop 本身</b>:拼上下文、调模型、分发工具调用、记录结果。它今天跑在你的电脑上,文章要把它搬走。
</div>
<h2>先看全景:三种摆法</h2>
<div class="layouts">
<div class="layout">
<h3>① 今天的本地 CLI</h3>
<div class="sub">Claude Code / Codex CLI / OpenCode 的常见用法</div>
<div class="tier cloud"><b>云端</b><span class="chip x">只有模型 API</span></div>
<div class="tier mine"><b>你的电脑</b>
<span class="chip c">Agent Loop 进程</span>
<span class="chip s">会话记录文件</span>
<span class="chip l">仓库 / 凭据 / 工具链</span>
</div>
<div class="verdict">三样东西都在一台机器上。合盖、断网、关终端,Agent 就停了。</div>
</div>
<div class="layout">
<h3>② 全搬上云(云端沙箱)</h3>
<div class="sub">文章 §5.2 提到的第一种常见做法</div>
<div class="tier cloud"><b>云端</b>
<span class="chip c">Agent Loop</span>
<span class="chip s">会话数据库</span>
<span class="chip l">沙箱:拉代码、重建环境</span>
</div>
<div class="tier mine"><b>你的电脑</b><span class="chip x">基本不参与</span></div>
<div class="verdict">一直在线,但你没提交的改动、本地凭据、内网服务,沙箱都拿不到,或者同步成本很高。</div>
</div>
<div class="layout pick">
<h3>③ 文章方案:§5.1 + §5.2</h3>
<div class="sub">计算、状态、执行环境三者分开</div>
<div class="tier cloud"><b>云端</b>
<span class="chip c">Agent Loop(无状态,可随时换)</span>
<span class="chip s">会话数据库</span>
</div>
<div class="tier mine"><b>你的电脑</b>
<span class="chip l">仓库 / 凭据 / 工具链</span>
<span class="chip l">轻量守护进程</span>
</div>
<div class="verdict">Loop 在云上一直在线,环境留在原地,工具调用穿过守护进程远程执行。<b>下面演示的就是这一种。</b></div>
</div>
</div>
<h2>逐步演示(方案 ③)</h2>
<div class="stage">
<div class="you" id="you"><span class="who">你</span><span id="youText">网页 / IM / CLI,都只是“发消息、看回复”的窗口</span></div>
<div class="zones">
<div class="zone compute" id="zCompute">
<h3>云端 · 计算</h3>
<div class="where">跑 Agent Loop 的容器,按需借用,用完即还</div>
<div id="computeBody"></div>
</div>
<div class="zone state" id="zState">
<h3>云端 · 状态(Session)</h3>
<div class="where">数据库里的只追加事件日志,唯一需要长期保存的东西</div>
<ul class="log" id="logBody"></ul>
</div>
<div class="zone local" id="zLocal">
<h3>你的电脑 · 执行环境</h3>
<div class="where">代码、改动、凭据、依赖都留在这里</div>
<div id="localBody"></div>
</div>
</div>
<div class="flow" id="flow"></div>
<div class="explain">
<div class="box">
<h4 id="title"></h4>
<p id="desc"></p>
<div class="cloudline" id="cloudline"></div>
</div>
<div class="box cli">
<h4>对照:如果是今天的本地 CLI</h4>
<p id="cli"></p>
</div>
</div>
<div class="controls">
<button id="prev">← 上一步</button>
<div class="dots" id="dots"></div>
<button id="next" class="primary">下一步 →</button>
</div>
<div class="hint" style="text-align:center;margin-top:6px">也可以用键盘 ← → 切换</div>
</div>
<h2>走完一遍,东西都在哪</h2>
<div class="summary">
<div class="box">
<h4 style="color:var(--compute)">云端 · 计算:什么都没留</h4>
<ul>
<li>前后用过 A、B、C 三个实例,都已经回收</li>
<li>它们内存里的东西都能从日志重建,丢了也没关系</li>
</ul>
</div>
<div class="box">
<h4 style="color:var(--state)">云端 · 状态:完整的事件日志</h4>
<ul>
<li>你的需求、模型每一步的决定</li>
<li>工具结果:读到的代码片段、diff 摘要、测试输出摘要</li>
<li>任务状态:等待设备、已完成</li>
</ul>
</div>
<div class="box">
<h4 style="color:var(--local)">你的电脑:真正干活的地方</h4>
<ul>
<li>两个文件的改动,<b>没提交</b>,等你 review</li>
<li>凭据、内网依赖、工具链,从来没离开过</li>
<li>测试是在这里真实跑的</li>
</ul>
</div>
</div>
<div class="takeaway">
一句话:<b>计算可以随时丢,因为状态存在别处;环境不用搬,因为工具调用可以远程执行。</b><br>
<span style="font-size:15px;color:var(--muted)">§5.1 让 Agent 不再依附某个进程,§5.2 让它不再依附某台机器。</span>
</div>
<p class="src">
依据:DeepSeek Harness《Super-agent 设计探索》第 5 节(<a href="https://xitter.cf/DeepSeekHarness/article/2116857257245987236">英文版镜像</a>),以及 Anthropic《<a href="https://www.anthropic.com/engineering/managed-agents">Scaling Managed Agents: Decoupling the brain from the hands</a>》中的 <code>wake(sessionId)</code>、<code>getSession(id)</code>、<code>emitEvent(id, event)</code> 接口。这是文章的架构设想,不代表 DeepSeek Harness 或公开原型已经实现;步骤中的事件、文件名和测试数量都是示意。
</p>
</main>
<script>
const STEPS = [
{
title: "1. 你提出需求",
desc: "你在网页或 IM 里发出需求。这条消息<b>先写进 Session</b>,而不是直接交给某个正在运行的进程。此时云端没有任何 Loop 在跑。",
cloud: "上云的:你的这条消息。",
cli: "消息直接进入你终端里那个进程的内存,同时追加到本机的会话记录文件里。进程和记录都在你的电脑上。",
on: ["you", "state"],
you: "发出:“登录令牌在过期那一秒还能用,修一下,并跑回归测试。”",
flow: "你 → <span>云端状态</span>",
compute: [],
computeEmpty: "空闲:没有实例在跑。计算是按需借用的。",
events: [["用户", "修令牌过期边界的 bug,并跑回归"]],
local: [
{ t: "仓库 auth-service", s: "main 分支,工作区干净" },
{ t: "本地凭据和内网 npm 源", s: ".env、~/.npmrc" },
{ t: "守护进程:在线", s: "只负责执行命令、返回结果,不保存会话" }
]
},
{
title: "2. 拉起一个 Loop 实例",
desc: "云端发现 Session 里有还没处理的事件,就分配一个<b>无状态容器</b>来跑 Agent Loop,记为实例 A。A 读取事件日志,拼出模型请求。模型的第一个决定是先读一下令牌校验的代码。",
cloud: "实例 A 的内存里只有从日志重建出来的消息列表,随时可以丢掉。",
cli: "本地 CLI 的 Loop 就是你启动的那个进程,一直开着,不需要“拉起”。代价是关掉终端它就没了。",
on: ["state", "compute"],
flow: "<span>云端状态</span> → <span>云端计算(实例 A)</span> → 模型 API",
compute: [
{ t: "实例 A · 运行中", s: "从日志重建上下文 → 调模型", isNew: true }
],
events: [["模型", "先读 src/auth/token.ts"]],
local: "same"
},
{
title: "3. 读代码:工具调用穿到你的电脑上",
desc: "代码在你的电脑上,<b>云端没有仓库副本</b>。实例 A 把 <code>read_file</code> 发给本地守护进程,守护进程读出文件返回,结果写进 Session。读到第 42 行:<code>if (now &gt; exp) reject()</code>。",
cloud: "上云的:<b>读到的那段代码</b>会作为工具结果进入 Session。“环境留在本地”不等于“代码一行都不上云”,模型需要看到的片段还是会上去。",
cli: "效果一样:文件也是在本地读的,读到的内容也会发给云端模型 API。区别只在于 Loop 和会话记录也在本地。",
on: ["compute", "local", "state"],
flow: "<span>云端计算</span> → <span>本地守护进程</span> → <span>云端计算</span> → <span>云端状态</span>",
compute: [{ t: "实例 A · 运行中", s: "等待本地返回工具结果" }],
events: [["工具调用", "read_file src/auth/token.ts"], ["工具结果", "第 42 行:if (now > exp) reject()"]],
local: [
{ t: "仓库 auth-service", s: "main 分支,工作区干净" },
{ t: "本地凭据和内网 npm 源", s: ".env、~/.npmrc" },
{ t: "守护进程:执行 read_file", s: "执行完即忘,不记会话", isNew: true }
]
},
{
title: "4. 改代码:改动只落在本地",
desc: "模型判断边界写错了:<code>now &gt; exp</code> 应该是 <code>now &gt;= exp</code>。<code>edit</code> 在你的电脑上执行,文件被改了,但<b>没有提交,也没有推送</b>。",
cloud: "上云的:这次修改的 diff 摘要(一行变更)。完整的工作区仍然只在本地。",
cli: "一样:改动也只在本地。",
on: ["compute", "local", "state"],
flow: "<span>云端计算</span> → <span>本地:修改文件</span> → <span>云端状态</span>",
compute: [{ t: "实例 A · 运行中", s: "" }],
events: [["工具调用", "edit token.ts:> 改成 >="], ["工具结果", "1 行变更"]],
local: [
{ t: "仓库 auth-service", s: "src/auth/token.ts 已修改(未提交)", isNew: true },
{ t: "本地凭据和内网 npm 源", s: ".env、~/.npmrc" },
{ t: "守护进程:在线", s: "" }
]
},
{
title: "5. 启动回归测试(异步)",
desc: "<code>pnpm test auth</code> 要跑一分钟,还要连内网服务,只能在你的电脑上跑。守护进程在本地启动测试,<b>立刻返回 job-17</b>,Loop 的这一步就结束了,不用一直等着。",
cloud: "上云的:“job-17 已启动”这条记录。测试进程本身在本地。",
cli: "同步执行的话,这一分钟里 Loop 一直卡在等结果。用后台任务(例如 Claude Code 的 run_in_background)才能先返回。",
on: ["compute", "local", "state"],
flow: "<span>云端计算</span> → <span>本地:启动测试</span> → <span>云端状态</span>",
compute: [{ t: "实例 A · 空闲", s: "这一步做完了,没有别的事" }],
events: [["工具调用", "后台运行 pnpm test auth"], ["工具结果", "已启动 job-17"]],
local: [
{ t: "仓库 auth-service", s: "src/auth/token.ts 已修改(未提交)" },
{ t: "本地凭据和内网 npm 源", s: "测试正在用它们" },
{ t: "进程:pnpm test auth", s: "job-17 · 运行中", isNew: true }
]
},
{
title: "6. 云端实例被回收(或崩溃)",
desc: "实例 A 闲着,被回收了。换成机器故障、版本发布、扩缩容,结果也一样。<b>A 内存里的东西全丢了,但没关系</b>:该记的都已经在 Session 里。你电脑上的测试照常在跑。",
cloud: "这一步就是“计算与状态分离”的核心:丢掉计算,什么都不会丢。",
cli: "本地 CLI 这时如果进程退出,它启动的后台测试一般也会被清理(Claude Code 文档写明退出时会自动清理后台任务),测试结果没有地方可以回。",
on: ["compute"],
flow: "<span>云端计算</span>:实例 A 消失。Session 和本地都不受影响",
compute: [{ t: "实例 A", s: "已回收", gone: true }],
events: [],
local: [
{ t: "仓库 auth-service", s: "src/auth/token.ts 已修改(未提交)" },
{ t: "本地凭据和内网 npm 源", s: "" },
{ t: "进程:pnpm test auth", s: "job-17 · 仍在运行" }
]
},
{
title: "7. 测试跑完,新实例接上",
desc: "测试结束,守护进程上报“job-17 退出码 0,24 个通过”,这条结果写进 Session,成为一个新事件。云端发现新事件,拉起<b>实例 B</b>:<code>wake(sessionId)</code> → 读出完整日志 → 从最后一个事件接着推理。模型决定再补一个“恰好过期”的测试用例。",
cloud: "实例 B 和 A 不是同一个进程,但 Session 是同一个。B 知道的一切都来自日志。",
cli: "本地 CLI 要“接着跑”,只能是同一个进程还活着;进程挂了就得手动 resume,而且后台测试的结果多半已经丢了。",
on: ["local", "state", "compute"],
flow: "<span>本地:测试结束</span> → <span>云端状态</span> → <span>云端计算(实例 B)</span> → 模型 API",
compute: [
{ t: "实例 A", s: "已回收", gone: true },
{ t: "实例 B · 运行中", s: "wake(sessionId) → 读日志 → 调模型", isNew: true }
],
events: [["后台完成", "job-17 退出码 0,24 个通过"], ["模型", "补一个“恰好过期”的测试用例"]],
local: [
{ t: "仓库 auth-service", s: "src/auth/token.ts 已修改(未提交)" },
{ t: "本地凭据和内网 npm 源", s: "" },
{ t: "进程:pnpm test auth", s: "job-17 · 已结束,24 个通过", isNew: true }
]
},
{
title: "8. 你合上了笔记本",
desc: "实例 B 要在本地新增测试文件,但你的电脑离线了,工具调用发不出去。任务被标记为“<b>等待设备上线</b>”,实例 B 也被回收。没有东西丢失,也没有东西在空转。",
cloud: "上云的:“等待设备”这个任务状态。计算区又空了。",
cli: "本地 CLI 合盖等于整个 Agent 暂停。进程被挂起,打开后正在进行的网络请求可能已经超时报错。",
on: ["local", "state"],
offLocal: true,
flow: "<span>本地:离线</span> → <span>云端状态</span>:记录“等待设备”",
compute: [],
computeEmpty: "空闲:实例 B 已回收,没有实例在等。",
events: [["工具调用", "edit token.spec.ts → 等待设备上线"]],
local: [
{ t: "仓库 auth-service", s: "src/auth/token.ts 已修改(未提交)" },
{ t: "本地凭据和内网 npm 源", s: "" },
{ t: "守护进程:离线", s: "电脑已合盖", isNew: true }
]
},
{
title: "9. 第二天打开电脑:自动续上",
desc: "守护进程重新连上,云端发现有任务在等这台设备,就拉起<b>实例 C</b>,执行挂起的那次工具调用:新增测试,再跑一遍回归。",
cloud: "文章原话的意思:个人设备重连后,云端 Agent 会自动开始并完成需要在这台设备上跑的任务。",
cli: "本地 CLI 没有“等设备”这个概念,因为 Loop 和设备是同一个东西:设备在,Loop 才在。",
on: ["local", "state", "compute"],
flow: "<span>本地:重新上线</span> → <span>云端状态</span> → <span>云端计算(实例 C)</span> → <span>本地:改文件、跑测试</span>",
compute: [{ t: "实例 C · 运行中", s: "读日志,执行挂起的工具调用", isNew: true }],
events: [["工具结果", "新增 1 个测试用例"], ["后台完成", "pnpm test auth:25 个通过"]],
local: [
{ t: "仓库 auth-service", s: "token.ts、token.spec.ts 已修改(未提交)", isNew: true },
{ t: "本地凭据和内网 npm 源", s: "" },
{ t: "守护进程:在线", s: "" }
]
},
{
title: "10. 交付:汇报给你,改动留在本地",
desc: "模型总结修了什么、怎么验证的,回复给你。代码改动还在你的电脑上,等你 review、提交。实例 C 回收,这次任务结束。",
cloud: "最后留在云端的,只有这份事件日志。",
cli: "结果差不多,但前提是这期间你的电脑一直开着、终端一直没关。",
on: ["state", "you", "compute"],
you: "收到:“已修复 token.ts 第 42 行的边界判断,新增 1 个用例,回归 25 个全部通过。改动未提交,请 review。”",
flow: "<span>云端计算</span> → <span>云端状态</span> → 你",
compute: [{ t: "实例 C", s: "已回收", gone: true }],
events: [["回复你", "已修复,新增 1 个用例,25 个通过,改动未提交"]],
local: [
{ t: "仓库 auth-service", s: "token.ts、token.spec.ts 已修改(未提交)" },
{ t: "本地凭据和内网 npm 源", s: "从未离开过这台电脑" },
{ t: "守护进程:在线", s: "空闲" }
]
}
];
// 本地状态 "same" 表示沿用上一步
for (let i = 0; i < STEPS.length; i++) {
if (STEPS[i].local === "same") {
STEPS[i].local = STEPS[i - 1].local.map(x => ({ t: x.t, s: x.s }));
}
}
const $ = id => document.getElementById(id);
let cur = 0;
function itemHTML(x) {
const cls = ["item"];
if (x.isNew) cls.push("new");
if (x.gone) cls.push("gone");
return `<div class="${cls.join(" ")}">${x.t}${x.s ? `<small>${x.s}</small>` : ""}</div>`;
}
function esc(s) {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
function render() {
const st = STEPS[cur];
$("title").innerHTML = st.title;
$("desc").innerHTML = st.desc;
$("cloudline").innerHTML = st.cloud;
$("cli").innerHTML = st.cli;
$("flow").innerHTML = "这一步的数据流:" + st.flow;
$("you").classList.toggle("on", st.on.includes("you"));
$("youText").textContent = st.you || "网页 / IM / CLI,都只是“发消息、看回复”的窗口";
$("zCompute").classList.toggle("on", st.on.includes("compute"));
$("zState").classList.toggle("on", st.on.includes("state"));
$("zLocal").classList.toggle("on", st.on.includes("local"));
$("zLocal").classList.toggle("off", !!st.offLocal);
$("computeBody").innerHTML = st.compute.length
? st.compute.map(itemHTML).join("")
: `<div class="empty">${st.computeEmpty || "空闲"}</div>`;
$("localBody").innerHTML = st.local.map(itemHTML).join("");
// 事件日志:累积
let n = 0, html = "";
for (let i = 0; i <= cur; i++) {
for (const [type, text] of STEPS[i].events) {
n++;
html += `<li class="${i === cur ? "new" : ""}"><span class="n">#${n}</span><span class="t">${type}</span><span>${esc(text)}</span></li>`;
}
}
$("logBody").innerHTML = html || `<li><span class="t">(空)</span></li>`;
const log = $("logBody");
log.scrollTop = log.scrollHeight;
$("prev").disabled = cur === 0;
$("next").disabled = cur === STEPS.length - 1;
[...$("dots").children].forEach((d, i) => {
d.className = "dot" + (i === cur ? " on" : i < cur ? " done" : "");
});
}
STEPS.forEach((_, i) => {
const b = document.createElement("button");
b.className = "dot";
b.textContent = i + 1;
b.title = STEPS[i].title;
b.onclick = () => { cur = i; render(); };
$("dots").appendChild(b);
});
$("prev").onclick = () => { if (cur > 0) { cur--; render(); } };
$("next").onclick = () => { if (cur < STEPS.length - 1) { cur++; render(); } };
document.addEventListener("keydown", e => {
if (e.key === "ArrowRight") $("next").click();
if (e.key === "ArrowLeft") $("prev").click();
});
render();
</script>
</body>
</html>

View File

@@ -0,0 +1,444 @@
import assert from "node:assert/strict";
import { execFileSync, spawn } from "node:child_process";
import { randomUUID } from "node:crypto";
import { once } from "node:events";
import {
appendFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync,
realpathSync, renameSync, rmSync, writeFileSync
} from "node:fs";
import { createServer } from "node:http";
import { createRequire } from "node:module";
import { homedir, tmpdir } from "node:os";
import { basename, join } from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import { fileURLToPath, pathToFileURL } from "node:url";
const self = fileURLToPath(import.meta.url);
const provider = "em-coding-messages";
const modelName = "DeepSeek-V4-Flash";
const fixtureFiles = ["orders.mjs", "orders.test.mjs"];
const request = `给这个订单列表加状态筛选。filterOrders(orders) 仍返回全部,传入 "pending" 或 "shipped" 时只返回匹配项,保持顺序。保留原测试和 before 钩子,用两个独立的 test(...) 分别测试 pending 和 shipped。
先用 read_file 看 orders.mjs 和 orders.test.mjs,再用 write_file 修改。最后调用 start_tests 启动真实测试,收到任务 ID 后简短汇报已启动并结束当前轮;完成事件稍后投递。`;
const followUp = "请报告刚收到的测试结果。再用 repo_diff 读取这两个文件此刻的真实工作区差异。如果工具返回 EXECUTOR_OFFLINE,就说明差异需要等设备重连,然后结束本轮。";
const [action = "record", firstArgument, phase] = process.argv.slice(2);
function recorder(directory, lane) {
return (kind, summary, data = null, route = [lane, lane]) => {
appendFileSync(join(directory, "events.jsonl"), JSON.stringify({
at: Date.now(), pid: process.pid, lane, kind, route, summary, data
}) + "\n");
};
}
function fixture(directory) {
const repo = join(directory, "repo");
mkdirSync(repo, { recursive: true });
writeFileSync(join(repo, "orders.mjs"), `export function filterOrders(orders) {
return orders;
}
`);
writeFileSync(join(repo, "orders.test.mjs"), `import assert from "node:assert/strict";
import { existsSync } from "node:fs";
import { before, test } from "node:test";
import { setTimeout } from "node:timers/promises";
import { filterOrders } from "./orders.mjs";
const orders = [
{ id: 101, status: "pending" },
{ id: 102, status: "shipped" },
{ id: 103, status: "pending" },
];
// The lab releases this gate after observing the Loop process state.
before(async () => {
const gate = process.env.AGENT_LAB_TEST_GATE;
if (!gate) return;
const deadline = Date.now() + 60_000;
while (!existsSync(gate)) {
assert(Date.now() < deadline, "Lab did not release the test gate");
await setTimeout(50);
}
});
test("defaults to all orders", () => {
assert.deepEqual(filterOrders(orders), orders);
});
`);
writeFileSync(join(repo, "notes.txt"), "Release notes\n");
git(repo, ["init", "-q"]);
git(repo, ["add", "."]);
git(repo, ["-c", "user.name=Agent Loop Lab", "-c", "user.email=lab@example.invalid",
"-c", "commit.gpgsign=false", "-c", "core.hooksPath=/dev/null", "commit", "-qm", "Order list fixture"]);
appendFileSync(join(repo, "notes.txt"), "Uncommitted local release note\n");
return repo;
}
function git(repo, args) {
return execFileSync("git", ["-C", repo, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
}
function snapshot(repo) {
return {
status: git(repo, ["status", "--short"]),
diff: git(repo, ["diff", "--", ...fixtureFiles]),
notes: readFileSync(join(repo, "notes.txt"), "utf8")
};
}
function makeExecutor(directory) {
const repo = join(directory, "repo");
const log = recorder(directory, "executor");
let job;
return async ({ id, name, input }) => {
log("tool.execute", "执行器开始 " + name, { id, name, input });
if (name === "read_file" || name === "write_file") {
if (!fixtureFiles.includes(input.path)) throw Error("Path outside the fixture allowlist");
const path = join(repo, input.path);
if (name === "read_file") return readFileSync(path, "utf8");
if (typeof input.content !== "string" || input.content.length > 8000) throw Error("Invalid file content");
writeFileSync(path, input.content);
log("git.snapshot", "写入后 Git 工作区变化", snapshot(repo));
return "Wrote " + input.path;
}
if (name === "repo_diff") return snapshot(repo).diff;
if (name !== "start_tests") throw Error("Unknown fixture tool");
if (job) return JSON.stringify(job);
const child = spawn(process.execPath, ["--test", "--test-reporter=tap", "orders.test.mjs"], {
cwd: repo, env: { PATH: process.env.PATH, AGENT_LAB_TEST_GATE: join(directory, "release-tests") },
stdio: ["ignore", "pipe", "pipe"]
});
job = { jobId: "orders-tests-1", pid: child.pid, status: "running", command: "node --test --test-reporter=tap orders.test.mjs" };
let stdout = "";
let stderr = "";
child.stdout.on("data", chunk => { stdout += chunk; });
child.stderr.on("data", chunk => { stderr += chunk; });
child.on("close", (code, signal) => {
const completed = { ...job, status: "finished", code, signal, stdout, stderr };
log("test.finish", "本机测试进程退出", completed);
writeFileSync(join(directory, "test-result.json"), JSON.stringify(completed));
});
log("test.start", "本机启动 Node 测试进程", job);
return JSON.stringify(job);
};
}
async function serve(directory) {
const log = recorder(directory, "executor");
const execute = makeExecutor(directory);
const server = createServer(async (req, res) => {
if (req.method !== "POST" || req.url !== "/tool") {
res.writeHead(404).end();
return;
}
try {
const chunks = [];
for await (const chunk of req) chunks.push(chunk);
const call = JSON.parse(Buffer.concat(chunks));
log("rpc.receive", "daemon 收到工具请求", call, ["loop", "executor"]);
const text = await execute(call);
log("rpc.send", "daemon 回传工具结果", { id: call.id, name: call.name, text }, ["executor", "loop"]);
res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ text }));
} catch {
res.writeHead(400).end(JSON.stringify({ text: "Fixture tool rejected the request" }));
}
});
const portFile = join(directory, "port");
server.listen(existsSync(portFile) ? Number(readFileSync(portFile, "utf8")) : 0, "127.0.0.1");
await once(server, "listening");
log("executor.start", "本机 daemon 监听 loopback", { port: server.address().port });
writeFileSync(portFile, String(server.address().port));
process.send({ ready: true });
}
function sanitizedPayload(payload) {
const { model, max_tokens, stream, system, messages, tools } = payload;
const scrub = value => {
if (Array.isArray(value)) return value.filter(item => !["thinking", "redacted_thinking"].includes(item?.type)).map(scrub);
if (!value || typeof value !== "object") return value;
return Object.fromEntries(Object.entries(value)
.filter(([key]) => !["signature", "metadata"].includes(key))
.map(([key, child]) => [key, scrub(child)]));
};
return scrub({ model, max_tokens, stream, system, messages, tools });
}
async function runLoop(directory, mode) {
const piCli = realpathSync(execFileSync("which", ["pi"], { encoding: "utf8" }).trim());
const piModule = new URL("../index.js", pathToFileURL(piCli));
const { Type } = await import(pathToFileURL(createRequire(piModule).resolve("typebox")).href);
const {
createAgentSession, DefaultResourceLoader, defineTool, ModelRuntime, SessionManager, SettingsManager
} = await import(piModule.href);
const log = recorder(directory, "loop");
const repo = join(directory, "repo");
const sessions = join(directory, "state");
const config = join(homedir(), ".pi", "agent");
const sdkDir = join(directory, "pi-sdk");
mkdirSync(sdkDir, { recursive: true });
mkdirSync(sessions, { recursive: true });
const settingsManager = SettingsManager.inMemory({
compaction: { enabled: false }, retry: { enabled: false }
});
const modelRuntime = await ModelRuntime.create({
authPath: join(sdkDir, "auth.json"),
modelsPath: join(config, "models.json"),
modelsStorePath: join(sdkDir, "models-store.json"),
allowModelNetwork: false
});
const model = modelRuntime.getModel(provider, modelName);
assert(model, "Configured V4 Flash model is unavailable");
const resourceLoader = new DefaultResourceLoader({
cwd: sdkDir, agentDir: sdkDir, settingsManager,
noExtensions: true, noSkills: true, noPromptTemplates: true, noThemes: true, noContextFiles: true,
systemPromptOverride: () => "You are a coding agent in an isolated demo repository. Follow the requested tool sequence and report observed results. Do not invent outputs.",
extensionFactories: [pi => {
pi.on("before_provider_request", event => {
log("model.request", "Pi 组装并发送 Anthropic Messages 请求", sanitizedPayload(event.payload), ["loop", "gateway"]);
});
pi.on("after_provider_response", event => {
log("model.http", "收到网关 HTTP 响应头", { status: event.status }, ["gateway", "loop"]);
});
}]
});
await resourceLoader.reload();
const rpc = mode !== "baseline";
const execute = rpc ? null : makeExecutor(directory);
const definitions = [
["read_file", "Read a file from the working tree", Type.Object({ path: Type.String() })],
["write_file", "Replace the contents of a file in the working tree", Type.Object({ path: Type.String(), content: Type.String() })],
["start_tests", "Start the repository's Node test command and return its job ID immediately", Type.Object({})],
["repo_diff", "Read current git diff for orders.mjs and orders.test.mjs", Type.Object({})]
];
const customTools = definitions.map(([name, description, parameters]) => defineTool({
name, label: name, description, parameters,
execute: async (id, input) => {
const call = { id, name, input };
log("tool.dispatch", "Loop 派发 " + name, call, ["loop", "executor"]);
let text;
let resultOrigin = "executor";
if (rpc) {
const port = readFileSync(join(directory, "port"), "utf8");
try {
const response = await fetch("http://127.0.0.1:" + port + "/tool", {
method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(call),
signal: AbortSignal.timeout(5000)
});
({ text } = await response.json());
} catch (error) {
const code = error.cause?.code || error.name;
log("rpc.offline", "Loop 无法连到本机 daemon", { id, name, code }, ["loop", "executor"]);
text = "EXECUTOR_OFFLINE: current workspace could not be read (" + code + ")";
resultOrigin = "loop";
}
} else {
text = await execute(call);
}
log("tool.result", resultOrigin === "executor" ? "工具结果交回 Loop" : "Loop 把连接失败转换成工具结果",
{ id, name, text }, [resultOrigin, "loop"]);
return { content: [{ type: "text", text }], details: {} };
}
}));
const receipt = join(directory, "session-path");
const restored = existsSync(receipt);
// In the split topology Pi's cwd does not point to the repository.
const cwd = rpc ? sdkDir : repo;
const sessionManager = restored
? SessionManager.open(readFileSync(receipt, "utf8"), sessions, cwd)
: SessionManager.create(cwd, sessions);
const { session } = await createAgentSession({
cwd, agentDir: sdkDir, resourceLoader, settingsManager, modelRuntime, sessionManager,
model, thinkingLevel: "off", noTools: "builtin", customTools
});
log(restored ? "session.restore" : "loop.start",
restored ? "新 Loop 进程恢复同一个 Session" : "启动 Pi SDK Loop", {
sessionId: session.sessionId, existingMessages: session.messages.length, mode
}, restored ? ["session", "loop"] : ["loop", "loop"]);
session.subscribe(event => {
if (event.type !== "message_end" || event.message.role !== "assistant") return;
const { stopReason, usage, content } = event.message;
log("model.reply", "模型返回 " + stopReason, {
stopReason, usage, content: content.filter(block => ["text", "toolCall"].includes(block.type))
}, ["gateway", "loop"]);
});
const prompt = async (text, origin = "user") => {
log("turn.input", "Loop 接收新输入", { text }, [origin, "loop"]);
await session.prompt(text);
assert.notEqual(session.messages.at(-1)?.stopReason, "error", "Pi reported a provider error");
assert.notEqual(session.messages.at(-1)?.stopReason, "aborted", "Pi run was aborted");
assert(session.sessionFile && existsSync(session.sessionFile), "Pi session was not persisted");
writeFileSync(receipt, session.sessionFile);
const entries = sessionManager.getEntries();
log("session.save", "Pi Session JSONL 已落盘", {
file: basename(session.sessionFile), sessionId: session.sessionId, entries: entries.length,
roles: entries.filter(entry => entry.type === "message").map(entry => entry.message.role)
}, ["loop", "session"]);
};
try {
if (mode === "baseline" || mode === "initial") {
await prompt(request);
if (mode === "baseline") {
writeFileSync(join(directory, "release-tests"), "");
await prompt(await completionMessage(directory) + "\n\n" + followUp, "controller");
}
} else if (mode === "offline") {
await prompt(await completionMessage(directory) + "\n\n" + followUp, "controller");
} else if (mode === "reconnect") {
await prompt("设备已重连。继续上轮挂起的请求,调用 repo_diff,返回当前实际 diff 摘要。", "controller");
}
} finally {
session.dispose();
log("loop.exit", "Loop 主动退出", { mode });
}
}
async function until(predicate, label, timeout = 70_000) {
const deadline = Date.now() + timeout;
while (!predicate()) {
if (Date.now() >= deadline) throw Error("Timed out: " + label);
await delay(50);
}
}
async function completionMessage(directory) {
const resultFile = join(directory, "test-result.json");
await until(() => existsSync(resultFile), "test completion");
const result = JSON.parse(readFileSync(resultFile, "utf8"));
return "本机完成事件 " + JSON.stringify({ jobId: result.jobId, exitCode: result.code, stdout: result.stdout });
}
function startChild(command, directory, mode = "") {
const child = spawn(process.execPath, [self, command, directory, mode], {
env: { ...process.env, PI_OFFLINE: "1" },
stdio: ["ignore", "ignore", "inherit", "ipc"]
});
const watchdog = setTimeout(() => child.kill(), 120_000);
const done = once(child, "exit").then(([code, signal]) => {
clearTimeout(watchdog);
return { code, signal, pid: child.pid };
});
return { child, done };
}
async function childLoop(directory, mode) {
const result = await startChild("loop", directory, mode).done;
assert.equal(result.code, 0, "Pi Loop child failed in phase " + mode);
return result;
}
async function startExecutor(directory) {
const execution = startChild("executor", directory);
await Promise.race([
once(execution.child, "message"),
execution.done.then(() => { throw Error("Executor exited before startup"); })
]);
return execution;
}
async function recordRun(root, mode) {
const directory = join(root, mode);
const repo = fixture(directory);
const log = recorder(directory, "controller");
log("git.snapshot", "实验开始时的工作区", snapshot(repo), ["executor", "executor"]);
let daemon;
try {
if (mode === "baseline") {
await childLoop(directory, "baseline");
} else {
daemon = await startExecutor(directory);
const loop = await childLoop(directory, "initial");
const events = readEvents(directory);
const test = events.find(event => event.kind === "test.start");
assert(test?.data?.pid, "Model did not launch the tests");
assert(!existsSync(join(directory, "test-result.json")), "Tests finished before observing Loop exit");
process.kill(test.data.pid, 0);
log("controller.observed", "确认 Loop 已退出,本机测试 PID 仍存活", {
loopPid: loop.pid, executorPid: daemon.child.pid, testPid: test.data.pid, testGate: "released after observation"
});
writeFileSync(join(directory, "release-tests"), "");
await completionMessage(directory);
daemon.child.kill();
const stopped = await daemon.done;
log("executor.stop", "停止 daemon,注入设备离线", stopped, ["executor", "executor"]);
await childLoop(directory, "offline");
assert(readEvents(directory).some(event => event.kind === "rpc.offline" && event.data.name === "repo_diff"),
"Model did not attempt to read diff while offline");
daemon = await startExecutor(directory);
await childLoop(directory, "reconnect");
}
const final = snapshot(repo);
log("git.snapshot", "实验完成后的工作区", final, ["executor", "executor"]);
assert(final.diff.includes("orders.mjs") && final.diff.includes("orders.test.mjs"), "Model did not change both files");
const test = JSON.parse(readFileSync(join(directory, "test-result.json"), "utf8"));
assert.equal(test.code, 0, "Fixture tests failed");
const passes = Number(test.stdout.match(/^# pass (\d+)$/m)?.[1]);
assert(passes >= 3, "Expected at least three passed test cases; recorded " + passes);
assert(final.notes.includes("Uncommitted local release note"), "Unrelated local edit changed");
const events = readEvents(directory);
const requests = events.filter(event => event.kind === "model.request");
const responses = events.filter(event => event.kind === "model.http");
assert(requests.length && requests.length === responses.length && responses.every(event => event.data.status === 200),
"Expected one successful gateway HTTP response per observed request");
const instances = events.filter(event => ["loop.start", "session.restore"].includes(event.kind));
assert.equal(new Set(instances.map(event => event.data.sessionId)).size, 1, "Session ID changed");
assert.equal(new Set(instances.map(event => event.pid)).size, mode === "split" ? 3 : 1, "Unexpected Loop instances");
assert(events.some(event => event.kind === "tool.result" && event.data.name === "repo_diff" && event.data.text === final.diff),
"Model did not read the final diff");
return { mode, events };
} finally {
if (daemon?.child.exitCode === null && daemon.child.signalCode === null) {
daemon.child.kill();
await daemon.done;
}
}
}
function readEvents(directory) {
return readFileSync(join(directory, "events.jsonl"), "utf8").trim().split("\n").map(line => JSON.parse(line));
}
async function record(output) {
assert(output, "Usage: node run-agent-loop-lab.mjs record <trace.json|existing lab HTML>");
const root = mkdtempSync(join(tmpdir(), "agent-loop-lab-"));
try {
const runs = [];
for (const mode of ["baseline", "split"]) {
console.error("Running " + mode + " with Pi SDK and " + modelName);
runs.push(await recordRun(root, mode));
console.error("Verified " + mode + " (" + runs.at(-1).events.length + " observed events)");
}
const trace = { capturedAt: new Date().toISOString(), model: modelName, protocol: "Anthropic Messages", runs };
if (output.endsWith(".html")) embedTrace(trace, output);
else writeFileSync(output, JSON.stringify(trace, null, 2));
} finally {
rmSync(root, { recursive: true, force: true });
}
}
function embedTrace(trace, htmlPath) {
const html = readFileSync(htmlPath, "utf8");
const section = /(<script type="application\/json" id="trace-data">)[\s\S]*?(<\/script>)/;
assert(section.test(html), "HTML lacks the lab trace-data script");
const serialized = JSON.stringify(trace).replaceAll("<", "\\u003c");
const temporary = htmlPath + "." + randomUUID() + ".tmp";
try {
writeFileSync(temporary, html.replace(section, (_, start, end) => start + serialized + end), { flag: "wx" });
renameSync(temporary, htmlPath);
} finally {
rmSync(temporary, { force: true });
}
}
try {
if (action === "record") await record(firstArgument);
else if (action === "embed") embedTrace(JSON.parse(readFileSync(firstArgument, "utf8")), phase);
else if (action === "executor") await serve(firstArgument);
else if (action === "loop") {
await runLoop(firstArgument, phase);
process.disconnect?.();
} else throw Error("Unknown lab action");
} catch (error) {
console.error(error.message);
process.exitCode = 1;
process.disconnect?.();
}