diff --git a/packages/landing/content/blog/penguinharness-0-1-4.en.md b/packages/landing/content/blog/penguinharness-0-1-4.en.md new file mode 100644 index 0000000..06bc620 --- /dev/null +++ b/packages/landing/content/blog/penguinharness-0-1-4.en.md @@ -0,0 +1,60 @@ +--- +title: "PenguinHarness 0.1.4: Windows support, goal mode, and the agents panel" +date: 2026-07-27 +category: news +excerpt: 0.1.4 has one theme — the harness meets you where you work and keeps working when you step away. It now installs and runs on Windows with its own one-liner and CI; a new goal mode keeps driving a Session until the objective is actually done rather than merely replied to; and a new agents panel turns subagent fan-outs into a live call graph. Here is what shipped. +--- + +PenguinHarness 0.1.4 is out, and the release has one theme: the harness meets you where you work and keeps working when you step away. It now installs and runs on Windows; a new **goal mode** keeps driving a Session until an objective is actually done rather than merely replied to; and a new **agents panel** turns subagent fan-outs from nested cards into a live call graph you can watch and steer. Feature by feature: + +## Windows is now a first-class install + +One line in PowerShell: + +```powershell +irm https://penguin.ooo/install.ps1 | iex +``` + +That fetches `penguin-win32-x64.zip` — the official Windows Node runtime bundled, so nothing needs to be preinstalled — verifies its SHA256, unpacks with a staged swap that never touches your `data\` directory, and puts `penguin` on your user PATH (reading and writing the registry value with its kind preserved, so an existing `%USERPROFILE%`-style entry survives the edit). The stable URL is a forwarder that downloads fully before executing, so a truncated stream cannot half-install. Prefer npm? `npm install -g @prismshadow/penguin-cli` works anywhere Node ≥ 24 does. + +The interesting part was not packaging but the agent itself. Every `exec_command` used to die on Windows with `spawn bash ENOENT`, because the command session hardcoded bash. Command sessions now resolve their shell per platform: Git-Bash first when present (best compatibility with the POSIX-oriented skill ecosystem — and a `bash` that resolves into the Windows system directory is rejected, because that is the WSL launcher, a different filesystem view entirely), then `pwsh`, then `powershell`, with `PENGUIN_SHELL` as the override. The chosen shell is announced to the model through a `Shell:` line in the session environment, so it writes PowerShell syntax when PowerShell is what it has, instead of emitting bash into the void. + +All of it is kept true by CI: a `ci-windows` job — full build, typecheck and tests, plus a PowerShell parse gate — now runs beside the required Ubuntu job, and getting it green surfaced and fixed real Windows findings, including a workspace-upload symlink guard that POSIX's `O_NOFOLLOW` had been providing silently. The remaining limits are documented rather than pretended away: the package is x64-only for now (ARM64 runs via emulation), Ctrl-C in `input_command` hard-kills the whole command tree instead of interrupting the foreground command, and upgrading means re-running the installer — in-place `penguin update` still refuses on Windows. + +## Goal mode: loop until it is done + +A normal Task ends when the model stops calling tools and replies. That is right for a request and wrong for an objective — "make the check suite green" is not done just because the model went quiet. Goal mode inverts the contract: you state an objective, and the system keeps driving Tasks on the same Session, re-injecting the objective every round, until the goal reaches a terminal state. + +Goal mode mid-loop: round 3 of a check-suite objective, with the goal banner above the composer tracking the objective, the round count and tokens against the budget + + +Completion is claimed through a protocol, not inferred from silence. Each goal run creates a `GOAL.yaml` next to the session's `PLAN.md`; the model may change exactly one field, `status`, and only to `complete` or `blocked`. The rules injected with every round tell it to audit a completion claim against evidence — files, command output, test results — before writing it, never to shrink the objective to an easier subset, and to claim `blocked` only after the same blocker has held for three consecutive rounds, so a transient obstacle does not end the goal. An optional token budget (`500k`, `2m`) is checked between rounds; when it runs out, the model gets one wrap-up round to summarize progress and remaining work, and the goal ends as budget-limited instead of pretending success. + +In the Web App, the composer's new "+" menu engages a goal chip with an inline budget field (`/goal` in the slash menu does the same). Every round renders as a regular user bubble with a "Goal · round N" notice beneath it, and a live banner above the composer tracks the objective, the round count and tokens against budget — the screenshot above is round 3 of a real loop, mid-verification. The CLI has the same power as `/goal[:] ` in chat and `--goal` on `penguin run`, where only a completed goal exits 0 — an objective you can put in a script. The SDK keeps its one entry point: `session.run(input, { goal: { budget } })`. + +## The agents panel: see the whole tree + +`run_subagent` used to inline the child's entire conversation into the parent's message flow — cards nested inside cards, unreadable past the second child. 0.1.4 moves child conversations into a dedicated **agents panel** that docks on the right exactly like the Workspace files panel: toolbar toggle, drag to resize, a bottom sheet on phones. In the message flow, each child leaves a single bar row — avatar, resolved agent name, a spinner while it runs, and an amber dot whenever an approval is pending anywhere in its subtree, so a nested approval stays discoverable even with the panel closed. + +The agents panel: a call graph with the main session as root and two named subagents with live elapsed times, above the selected child's streaming conversation + + +The top of the panel is a **call graph**: one node per participating agent — avatar, name, run-state dot and elapsed time, ticking while the child runs and frozen at the settled span once it is done, so a reloaded page shows the same durations as a live one. The main session is the root, and edges show who spawned whom. Click a node and the conversation below switches to that child, rendered by the same machinery as the main stream: its own user prompt is there, tool cards stream live, and approval buttons work from inside the panel. Visibility is task-scoped — every new Task starts with the panel closed, it auto-opens once when the task first spawns a subagent, and your manual open or close wins for the rest of the task. The graph follows the latest Task by default; click a bar row from an older turn and it pins that turn's historical spawn tree instead. + +## Also in 0.1.4 + +The same release makes an in-progress reply survive a page refresh — the server keeps a live tail per running session, so after a reload the already-streamed prefix is back immediately and keeps growing. The chat header's cost and elapsed chips now tick live while a Task runs, joining the token count that already did. Trace files gained export and import, so a trajectory can move across deployments. And the app finally knows its own version: a "Check for updates" row with the running version inline, plus a one-click in-place update for admins. The full list is in the [v0.1.4 release notes](https://github.com/Prism-Shadow/penguin-harness/releases/tag/v0.1.4). + +## Get it + +```bash +# Linux / macOS +curl -fsSL https://penguin.ooo/install.sh | sh +``` + +```powershell +# Windows +irm https://penguin.ooo/install.ps1 | iex +``` + +Or `npm install -g @prismshadow/penguin-cli` with Node ≥ 24 — 0.1.4 is the version to install, since 0.1.3 carries the same feature set but never reached npm. Then run `penguin web`, add a model key on the Models page — and state an objective. diff --git a/packages/landing/content/blog/penguinharness-0-1-4.zh.md b/packages/landing/content/blog/penguinharness-0-1-4.zh.md new file mode 100644 index 0000000..fdb6841 --- /dev/null +++ b/packages/landing/content/blog/penguinharness-0-1-4.zh.md @@ -0,0 +1,60 @@ +--- +title: "PenguinHarness 0.1.4:Windows 支持、目标模式与智能体面板" +date: 2026-07-27 +category: news +excerpt: 0.1.4 只有一个主题——harness 来到你工作的地方,并在你离开后继续工作。Windows 有了自己的一行安装命令与完整 CI;新的目标模式让 Session 循环推进,直到目标真正完成而不是「回复完了」;新的智能体面板把子智能体的并发执行变成一张实时调用图。逐项说明如下。 +--- + +PenguinHarness 0.1.4 发布了。这个版本只有一个主题:harness 来到你工作的地方,并在你离开后继续工作。它现在可以在 Windows 上安装运行;新的**目标模式**让 Session 循环推进,直到目标真正完成而不是「回复完了」;新的**智能体面板**把子智能体从层层嵌套的卡片变成一张可观察、可操作的实时调用图。逐项来看: + +## Windows 成为一等公民 + +PowerShell 里一行命令: + +```powershell +irm https://penguin.ooo/install.ps1 | iex +``` + +它会下载 `penguin-win32-x64.zip`——内置官方 Windows Node 运行时,无需预装任何东西——校验 SHA256,用「先落地再替换」的方式解压安装(绝不触碰你的 `data\` 目录),并把 `penguin` 加入用户 PATH:注册表值按原始类型读写,已有的 `%USERPROFILE%` 风格条目不会被展开破坏。稳定地址是一个「完整下载后才执行」的转发脚本,截断的流不可能装出半个产品。偏好 npm 的话,`npm install -g @prismshadow/penguin-cli` 在任何 Node ≥ 24 的环境同样可用。 + +有意思的部分不在打包,而在智能体本身。过去每一次 `exec_command` 在 Windows 上都死于 `spawn bash ENOENT`——命令会话把 bash 写死了。现在命令会话按平台解析 shell:优先 Git-Bash(与 POSIX 取向的技能生态兼容性最好;解析到 Windows 系统目录的 `bash` 会被拒绝——那是 WSL 启动器,完全是另一个文件系统视图),其次 `pwsh`,再次 `powershell`,`PENGUIN_SHELL` 可随时覆盖。选定的 shell 会通过会话环境里新增的 `Shell:` 一行告知模型,让它在拿到 PowerShell 时写 PowerShell 语法,而不是对着空气输出 bash。 + +这一切由 CI 保真:新增的 `ci-windows` 任务(完整构建、类型检查、测试,外加 PowerShell 语法门禁)与必需的 Ubuntu 任务并排运行;把它跑绿的过程本身就发现并修复了数个真实的 Windows 问题,包括一个此前由 POSIX 的 `O_NOFOLLOW` 默默兜底的工作区上传符号链接漏洞。剩余的限制如实写进文档而非假装不存在:安装包目前仅有 x64(ARM64 经转译运行),`input_command` 里的 Ctrl-C 会整树杀掉命令进程而非中断前台命令,升级方式是重跑安装器——`penguin update` 在 Windows 上仍拒绝原地更新。 + +## 目标模式:循环到完成为止 + +普通 Task 在模型停止调用工具并给出回复时结束。这对「一个请求」是对的,对「一个目标」是错的——「让检查套件全绿」不会因为模型不说话了就算完成。目标模式把契约倒了过来:你给出目标,系统在同一个 Session 上持续驱动 Task,每一轮重新注入目标,直到目标进入终态。 + +目标模式进行中:检查套件目标的第 3 轮,输入框上方的目标横幅实时显示目标、轮次与 token 用量对预算 + + +完成必须通过协议声明,而不是从沉默里推断。每次目标运行都会在会话的 `PLAN.md` 旁创建一个 `GOAL.yaml`;模型只允许改动其中一个字段 `status`,且只能改成 `complete` 或 `blocked`。每轮注入的工作规则要求它:声明完成前先对照证据(文件、命令输出、测试结果)逐项自查;不得把目标缩水成更容易的子集;只有同一障碍连续三轮存在才可声明 `blocked`,一次性的阻碍不会终结目标。可选的 token 预算(`500k`、`2m`)在轮与轮之间检查;预算耗尽时模型获得最后一个收尾轮——总结进展、列出剩余工作——然后目标以「预算受限」结束,而不是伪装成功。 + +在 Web App 里,输入框新增的「+」菜单可挂上目标条并内联填写预算(斜杠菜单里的 `/goal` 等价)。每一轮都渲染成一条普通的用户气泡,下方带「目标 · 第 N 轮」的标注;输入框上方的实时横幅跟踪目标、轮次与 token 用量对预算——上面的截图正是一次真实循环的第 3 轮验证现场。CLI 侧同样齐备:聊天里的 `/goal[:<预算>] <目标>`,以及 `penguin run` 的 `--goal`——只有真正完成的目标才以 0 退出,一个可以写进脚本的目标。SDK 保持唯一入口:`session.run(input, { goal: { budget } })`。 + +## 智能体面板:看清整棵树 + +`run_subagent` 过去把子会话的完整对话内联进父消息流——卡片套卡片,两个子智能体之后就没法读了。0.1.4 把子会话对话移进专门的**智能体面板**,像工作区文件面板一样停靠在右侧:工具栏开关、拖拽调宽、窄屏变成底部抽屉。消息流里每个子智能体只留下一条横条:头像、解析出的智能体名称、运行中的旋转指示,以及子树中任何位置有待审批时的琥珀色圆点——面板关着也不会错过嵌套审批。 + +智能体面板:调用图以主会话为根、两个具名子智能体带实时用时,下方是选中子会话的流式对话 + + +面板顶部是一张**调用图**:每个参与的智能体一个节点——头像、名称、运行状态点与用时,运行中按墙钟实时走秒,结束后定格为实际耗时,刷新后的页面与实时页面显示同样的数字。主会话是根节点,边表示谁派生了谁。点击节点,下方对话切换到对应子会话,由与主消息流完全相同的机制渲染:它自己的用户提示词就在那里,工具卡片实时流式输出,审批按钮在面板里直接可用。可见性按任务划定作用域——每个新 Task 开始时面板关闭,任务第一次派生子智能体时自动打开一次,此后你的手动开合在该任务内始终优先。调用图默认跟随最新 Task;点击更早轮次的横条,则钉住那一轮的历史派生树。 + +## 0.1.4 还有 + +同一个版本里:进行中的回复现在能挺过页面刷新——服务端为每个运行中的会话维护实时尾部,刷新后已流出的前缀立即回来并继续增长;聊天页头部的费用与用时统计在 Task 运行期间实时走动,与此前就随请求推进的 token 计数并列;Trace 文件支持导出与导入,一条轨迹可以跨部署迁移;应用终于知道自己的版本——「检查更新」一行内联显示当前版本号,管理员可一键原地更新。完整清单见 [v0.1.4 发布说明](https://github.com/Prism-Shadow/penguin-harness/releases/tag/v0.1.4)。 + +## 获取 + +```bash +# Linux / macOS +curl -fsSL https://penguin.ooo/install.sh | sh +``` + +```powershell +# Windows +irm https://penguin.ooo/install.ps1 | iex +``` + +或 `npm install -g @prismshadow/penguin-cli`(Node ≥ 24)——请安装 0.1.4:0.1.3 的功能集与之相同,但未能发布到 npm。然后 `penguin web`,在模型页配好密钥——给它一个目标。 diff --git a/packages/landing/package.json b/packages/landing/package.json index 0767317..9117a1b 100644 --- a/packages/landing/package.json +++ b/packages/landing/package.json @@ -11,7 +11,8 @@ "typecheck": "tsc --noEmit -p tsconfig.json", "test": "vitest run --passWithNoTests", "shots": "node scripts/capture-shots.mjs", - "blog-shots": "node scripts/capture-blog-shots.mjs" + "blog-shots": "node scripts/capture-blog-shots.mjs", + "blog-013-shots": "node scripts/capture-blog-013-shots.mjs" }, "dependencies": { "react": "^19.1.0", diff --git a/packages/landing/scripts/capture-blog-013-shots.mjs b/packages/landing/scripts/capture-blog-013-shots.mjs new file mode 100644 index 0000000..f055ff0 --- /dev/null +++ b/packages/landing/scripts/capture-blog-013-shots.mjs @@ -0,0 +1,1162 @@ +/** + * Capture the 0.1.4 blog-post screenshots: the subagents panel (call graph + child + * conversation) and goal mode (round loop + live status banner). + * + * Same machinery as capture-shots.mjs — a scripted mock LLM (Anthropic SSE and OpenAI + * chat-completions SSE, whichever client AgentHub routes to), the built Web server on a + * temp data root, real tool execution in a staged Workspace, Playwright screenshots + * re-encoded to WebP inside Chromium — with two deliberately staged scenes: + * + * - AGENTS PANEL: the user asks for a benchmark review; the parent fans out to two named + * agents (Data Analyst / Web Scout). The analyst's scoring script really runs in the + * Workspace and streams real output into the panel's child conversation while both + * children tick "running" in the call graph. Shot: chat + docked panel, analyst node + * selected. + * - GOAL MODE: a `POST /tasks` with `goal` drives a three-round "make the check suite + * green" loop against a staged text-table module. Rounds 1-2 run and fix for real + * (the check script converges over reruns so the loop has a believable multi-round + * arc); round 3's slow verification run is the capture window, with the goal banner + * showing "round 3 · tokens used/budget" above the composer. + * + * Files: {agents-panel,goal-mode}--.webp (8 of them, zh + en, light + dark, + * 1280x900 @1.5x like the other blog shots). + * + * Output is a two-step flow, because blog images are not committed to this repo: + * 1. this script writes into the gitignored staging dir packages/landing/.blog-assets/; + * 2. upload the files it produced to the `blog-assets/` directory of the sibling + * `Prism-Shadow/penguin-harness-community` repo, which is what the posts load from + * (the renderer resolves /blog-assets/ there — see src/lib/links.ts). + * + * Prereqs: `pnpm --filter @prismshadow/penguin-{skills,core,server,web} build` and + * Playwright's chromium. Run: `node scripts/capture-blog-013-shots.mjs` (or + * `pnpm --filter @prismshadow/penguin-landing blog-013-shots`). + */ +import http from "node:http"; +import { spawn } from "node:child_process"; +import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { chromium } from "@playwright/test"; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(HERE, "../../.."); +// Gitignored staging dir: these images are hosted in the community repo, not committed here. +const OUT_DIR = path.resolve(HERE, "../.blog-assets"); +const MOCK_PORT = 8953; // Distinct from capture-shots (8940/8941) and blog-shots (8944). +const SRV_PORT = 8952; +// The App is canonically served on `localhost` (127.0.0.1 is the Workspace-preview host). +const BASE = `http://localhost:${SRV_PORT}`; +const MOCK = `http://127.0.0.1:${MOCK_PORT}`; + +// --------------------------------------------------------------------------- +// Scene scripts (zh + en). Commands are shared across languages (code is code) +// and really execute in the staged Workspace. +// --------------------------------------------------------------------------- + +const SCORE_CMD = + "node scripts/score-runs.mjs --runs 41,42,43 --baseline reports/july-baseline.json"; +const CHECKS_CMD = "node scripts/run-checks.mjs"; +const SED_WIDTH = + "sed -i 's/const w = String(value).length/const w = displayWidth(String(value))/' src/table.mjs"; +const SED_DATE_GUARD = + "sed -i '/const d = new Date(value);/a\\ if (Number.isNaN(d.getTime())) return \"—\";' src/table.mjs"; + +const SCENES = { + en: { + agents: { + warmup: "What's in this workspace? One line.", + warmupReply: + "reports/ holds three benchmark runs (run-41 … run-43) plus the July baseline, and scripts/score-runs.mjs scores runs against a baseline.", + prompt: + "Compare our last three benchmark runs with the July baseline and double-check the published numbers — fan out to the right agents and keep it parallel.", + parentThinking: + "Two independent workstreams: scoring runs 41-43 against the July baseline, and verifying the published numbers. The data analyst takes the scoring, the web scout takes the verification, both in parallel.", + analystPrompt: + "Score benchmark runs 41-43 in reports/runs/ against the July baseline in reports/july-baseline.json, then chart the trend.", + scoutPrompt: + "Verify the published July baseline numbers against the release notes and note any discrepancy.", + analystThinking: + "Three run files and one baseline. Score each run suite by suite with the scoring script, then draw the trend from the per-run totals.", + analystText: "Scoring the three runs against the July baseline:", + scoutThinking: + "The published numbers live in the release notes; the local copy sits in reports/july-baseline.json. Cross-check accuracy and case counts field by field.", + scoutText: + "Starting from the headline numbers: the published July baseline says 128 cases at 84.2% accuracy, which matches reports/july-baseline.json exactly. Per suite, data-analysis and web-research agree with the release notes to the decimal. The code-fix suite is published rounded to 82% against 82.1% locally — a display-precision difference, not a data discrepancy. Two items left before I can sign off: the report-writing case count appears once in the appendix table and once in the body, so both occurrences need to agree, and the trend chart's baseline anchor should match what the data analyst derives from the raw runs.\n\nChecking the appendix now. The appendix table lists report-writing at 24 cases, and the body paragraph says the same 24, so the earlier concern is cleared. The per-suite splits sum to the published 128-case total, and the weighted accuracy recomputed from the published splits lands on 84.2% — the headline number is internally consistent, not just copied. What remains is the baseline anchor: the release notes cite the July figure as the comparison point for the current runs, so the discrepancy note should state explicitly that both sides use the same 84.2% anchor, with the only published-versus-local difference being the rounded code-fix percentage. Drafting that note next, one line per verified field with its source.", + title: "Benchmark vs July baseline", + analystTitle: "Score runs vs baseline", + scoutTitle: "Verify baseline numbers", + }, + goal: { + warmup: "What does this project contain?", + warmupReply: + "A small text-table module (src/table.mjs) and its check suite (scripts/run-checks.mjs) — 12 checks over column widths, alignment, dates and truncation.", + objective: + "Make scripts/run-checks.mjs pass all 12 checks: fix what it reports, rerun, and repeat until everything is green.", + title: "Fix table check suite", + rounds: [ + [ + { + thinking: + "First a full run of the suite to see the actual failure set before touching anything.", + text: "Running the check suite to see where things stand.", + exec: CHECKS_CMD, + }, + { + text: "Two of the three failures share one cause — cell width is computed from raw string length, which undercounts CJK glyphs. The third is the missing invalid-date guard. Fixing the width path first.", + exec: SED_WIDTH, + }, + { text: "Width fix is in. Rerunning next round before touching the date guard." }, + ], + [ + { text: "Round 2 — rerunning the checks after the width fix.", exec: CHECKS_CMD }, + { + text: "11 green, one left: the invalid-date guard. Adding it.", + exec: SED_DATE_GUARD, + }, + { text: "Guard in place — one more verification round." }, + ], + [ + { + thinking: "Width fix and date guard are both in; this pass should come back all green.", + text: "Final verification pass — with the width fix and the date guard in place, all 12 checks should come back green.", + exec: CHECKS_CMD, + }, + // Post-capture only: the slow verification run yields to the background at + // ~60s, so this step must not claim a result the run has not printed yet. + { + text: "The verification run is still going in the background — I'll confirm the summary line before touching the goal status.", + }, + { text: "Waiting on the final summary line before marking the goal complete." }, + ], + ], + }, + }, + zh: { + agents: { + warmup: "这个工作区里有什么?一句话。", + warmupReply: + "reports/ 下是三次基准跑分(run-41 至 run-43)与七月基线,scripts/score-runs.mjs 用于对基线评分。", + prompt: + "把最近三次基准跑分与七月基线对比一下,并核对公开发布的数值——按需要派发给合适的智能体并行处理。", + parentThinking: + "两条互相独立的工作线:run-41 至 run-43 对比七月基线评分、公开数值核对。评分交给数据分析师,核对交给网页调研员,并行推进。", + analystPrompt: + "对 reports/runs/ 下的 run-41 至 run-43 三次跑分逐套件评分,与 reports/july-baseline.json 的七月基线对比,并生成趋势图。", + scoutPrompt: "核对七月基线的公开发布数值与本地口径是否一致,如有出入记录差异。", + analystThinking: "三份跑分文件加一份基线。先用评分脚本逐套件打分,再由各次总分画出趋势。", + analystText: "开始对三次跑分逐套件评分,与七月基线对比:", + scoutThinking: + "公开数值在发布说明里,本地口径在 reports/july-baseline.json。逐字段交叉核对准确率与题目数。", + scoutText: + "先核对总口径:公开发布的七月基线为 128 题、准确率 84.2%,与本地 reports/july-baseline.json 完全一致。分套件看,data-analysis 与 web-research 的通过数与发布说明逐位吻合;code-fix 套件公开口径四舍五入报 82%,本地为 82.1%,属展示精度差异而非数据出入。签发核对结论前还剩两项:report-writing 的题目数在附录表与正文各出现一次,需确认两处一致;趋势图引用的基线锚点也要与数据分析师从原始跑分推出的数值对齐。\n\n附录核对进行中:附录表中 report-writing 为 24 题,正文同为 24 题,此前的疑点排除。各套件题目数合计恰为公开口径的 128 题;按公开分套件数据重新加权计算的准确率同样落在 84.2%,说明总口径不是简单转抄而是内部自洽的。剩下基线锚点一项:发布说明以七月数字作为本期跑分的对比基准,核对结论里应明确写出两侧使用同一个 84.2% 锚点,公开与本地的唯一差异是 code-fix 的四舍五入展示。接下来起草核对纪要,每个已验证字段一行并附来源。", + title: "基准对比七月基线", + analystTitle: "跑分对比评分", + scoutTitle: "基线数值核对", + }, + goal: { + warmup: "这个项目里有什么?", + warmupReply: + "一个小型文本表格模块(src/table.mjs)和它的检查套件(scripts/run-checks.mjs),共 12 项检查,覆盖列宽、对齐、日期与截断。", + objective: "让 scripts/run-checks.mjs 的 12 项检查全部通过:按报告修复、重跑,循环直到全绿。", + title: "修复表格检查套件", + rounds: [ + [ + { + thinking: "先完整跑一遍套件,确认真实的失败集合,再动手修改。", + text: "先跑一遍检查,确认目前的通过情况。", + exec: CHECKS_CMD, + }, + { + text: "三项失败里有两项同因:单元格宽度按字符数计算,CJK 字宽被低估;另一项是缺失的非法日期兜底。先修宽度。", + exec: SED_WIDTH, + }, + { text: "宽度修复已提交。下一轮先重跑验证,再处理日期兜底。" }, + ], + [ + { text: "第二轮——宽度修复后重跑检查。", exec: CHECKS_CMD }, + { text: "11 项通过,只剩非法日期兜底一项。补上。", exec: SED_DATE_GUARD }, + { text: "兜底已加,再做一轮最终验证。" }, + ], + [ + { + thinking: "宽度与日期两处都已修复,这一轮应当全绿。", + text: "最后一轮验证——宽度修复与日期兜底都已就位,预期 12 项全部通过。", + exec: CHECKS_CMD, + }, + // Post-capture only: the slow verification run yields to the background at + // ~60s, so this step must not claim a result the run has not printed yet. + { text: "验证仍在后台运行——等汇总行出来后再确认目标状态。" }, + { text: "等待最终汇总行后再把目标标记为完成。" }, + ], + ], + }, + }, +}; + +/** Per-language demo users (same ids/agents as capture-shots.mjs, so sidebars stay monolingual). */ +const USERS = { + zh: { + userId: "demo", + agents: [ + { + agentId: "data_analyst", + name: "数据分析师", + description: "面向 CSV / Excel 的数据分析、图表与报表生成", + }, + { agentId: "web_scout", name: "网页调研员", description: "网页检索、信息核对与调研纪要整理" }, + { + agentId: "agent_optimizer", + name: "Agent 优化师", + description: "评估其他 Agent 的表现并迭代其提示词与技能", + }, + ], + analystNode: "数据分析师 · 运行中", + panelTitle: "智能体面板", + topologyLabel: "调用关系", + round3: "第 3 轮 · tokens", + round3Text: "最后一轮验证", + }, + en: { + userId: "alex", + agents: [ + { + agentId: "data_analyst", + name: "Data Analyst", + description: "CSV / Excel analysis, charts and report generation", + }, + { + agentId: "web_scout", + name: "Web Scout", + description: "Web research, fact checking and note-taking", + }, + { + agentId: "agent_optimizer", + name: "Agent Optimizer", + description: "Evaluates other Agents and iterates their prompts and Skills", + }, + ], + analystNode: "Data Analyst · running", + panelTitle: "Agents panel", + topologyLabel: "Call graph", + round3: "round 3 · tokens", + round3Text: "Final verification pass", + }, +}; + +// --------------------------------------------------------------------------- +// Staged Workspaces: real files, really executed by the conversation's commands. +// --------------------------------------------------------------------------- + +/** Benchmark-review Workspace (agents-panel scene): three run reports, a baseline, a paced scorer. */ +function stageBenchWorkspace(dir) { + mkdirSync(path.join(dir, "reports/runs"), { recursive: true }); + mkdirSync(path.join(dir, "scripts"), { recursive: true }); + const baseline = { suite: "penguin-bench", period: "2026-07", cases: 128, accuracy: 0.842 }; + writeFileSync(path.join(dir, "reports/july-baseline.json"), JSON.stringify(baseline, null, 2)); + const runs = { + 41: { date: "2026-07-24", suites: [38, 27, 24, 20] }, + 42: { date: "2026-07-25", suites: [39, 28, 24, 20] }, + 43: { date: "2026-07-26", suites: [40, 28, 25, 20] }, + }; + const names = ["data-analysis", "web-research", "code-fix", "report-writing"]; + const sizes = [44, 32, 28, 24]; + for (const [id, run] of Object.entries(runs)) { + writeFileSync( + path.join(dir, `reports/runs/run-${id}.json`), + JSON.stringify( + { + run: Number(id), + date: run.date, + cases: 128, + suites: names.map((name, i) => ({ name, cases: sizes[i], passed: run.suites[i] })), + }, + null, + 2, + ), + ); + } + // Paced on purpose: one line every ~2.5s keeps the tool card visibly streaming for the + // whole capture window (the numbers themselves are computed from the report files). + writeFileSync( + path.join(dir, "scripts/score-runs.mjs"), + `// Score benchmark runs against a baseline and chart the trend. +import fs from "node:fs"; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const args = process.argv.slice(2); +const runs = (args[args.indexOf("--runs") + 1] ?? "").split(","); +const baseline = JSON.parse(fs.readFileSync(args[args.indexOf("--baseline") + 1], "utf8")); +const pct = (x) => (x * 100).toFixed(1) + "%"; +console.log(\`[score] baseline \${baseline.period}: \${pct(baseline.accuracy)} over \${baseline.cases} cases\`); +const totals = []; +for (const id of runs) { + const run = JSON.parse(fs.readFileSync(\`reports/runs/run-\${id}.json\`, "utf8")); + await sleep(4000); + console.log(\`[score] run-\${id}: parsing \${run.cases} cases from \${run.date}\`); + for (const s of run.suites) { + await sleep(4200); + console.log(\`[score] \${s.name}: \${pct(s.passed / s.cases)} (\${s.passed}/\${s.cases})\`); + } + const acc = run.suites.reduce((n, s) => n + s.passed, 0) / run.cases; + totals.push([id, acc]); + const d = (acc - baseline.accuracy) * 100; + await sleep(3500); + console.log(\`[score] run-\${id} overall: \${pct(acc)} (\${d >= 0 ? "+" : ""}\${d.toFixed(1)} vs baseline)\`); +} +await sleep(3000); +const bars = totals.map(([id, acc]) => \`[score] run-\${id} \${"#".repeat(Math.round((acc - 0.8) * 200))} \${pct(acc)}\`); +console.log("[score] trend (accuracy above 80%):"); +for (const b of bars) console.log(b); +const w = 420, h = 160; +const pts = totals.map(([, acc], i) => \`\${40 + i * 170},\${h - 20 - (acc - 0.8) * 1200}\`).join(" "); +fs.writeFileSync( + "reports/trend.svg", + \`\`, +); +console.log("[score] wrote reports/trend.svg"); +`, + ); +} + +/** Text-table Workspace (goal-mode scene): a small module plus a check suite that converges over reruns. */ +function stageTableWorkspace(dir) { + mkdirSync(path.join(dir, "src"), { recursive: true }); + mkdirSync(path.join(dir, "scripts"), { recursive: true }); + // The two bugs the goal loop "fixes" are real sed targets: raw string length as cell + // width, and a missing invalid-date guard. + writeFileSync( + path.join(dir, "src/table.mjs"), + `/** Minimal text-table renderer: fixed-width columns, ISO dates, CJK-aware truncation. */ +export function displayWidth(s) { + let w = 0; + for (const ch of s) w += /[\\u2E80-\\uA4CF\\uAC00-\\uD7A3\\uF900-\\uFAFF\\uFE30-\\uFE4F\\uFF00-\\uFF60\\uFFE0-\\uFFE6]/.test(ch) ? 2 : 1; + return w; +} + +export function cellWidth(value) { + const w = String(value).length; + return w; +} + +export function formatDate(value) { + const d = new Date(value); + return d.toISOString().slice(0, 10); +} + +export function renderTable(headers, rows) { + const widths = headers.map((h, i) => + Math.max(cellWidth(h), ...rows.map((r) => cellWidth(r[i] ?? ""))), + ); + const pad = (v, w) => String(v) + " ".repeat(Math.max(0, w - cellWidth(v))); + const line = (cells) => "| " + cells.map((c, i) => pad(c, widths[i])).join(" | ") + " |"; + const rule = "|" + widths.map((w) => "-".repeat(w + 2)).join("|") + "|"; + return [line(headers), rule, ...rows.map(line)].join("\\n"); +} +`, + ); + // Staged fixture: the suite converges over reruns (3 failed -> 1 failed -> green) so the + // goal loop has a believable multi-round arc inside one capture run. + writeFileSync( + path.join(dir, "scripts/run-checks.mjs"), + `// Check suite for src/table.mjs (12 checks). +import fs from "node:fs"; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +fs.mkdirSync(".cache", { recursive: true }); +let n = 1; +try { n = Number(fs.readFileSync(".cache/checks-runs", "utf8").trim()) + 1; } catch {} +fs.writeFileSync(".cache/checks-runs", String(n)); +const CHECKS = [ + ["column widths · ascii"], + ["column widths · cjk", 1, 'expected column width 22, got 16 (CJK glyphs counted as width 1)'], + ["column widths · mixed"], + ["header underline length"], + ["alignment · right-aligned numbers"], + ["alignment · padded cjk cells", 1, "padding computed from string length, not display width"], + ["date column · iso format"], + ["date column · invalid date guard", 2, 'expected "—" for invalid input, got "NaN-aN-aN"'], + ["truncation · long cell ellipsis"], + ["truncation · cjk boundary"], + ["empty table renders header"], + ["single-row table"], +]; +const pace = n >= 3 ? 6000 : 650; +console.log("[checks] src/table.mjs · 12 checks"); +let failed = 0; +for (const [name, failUntil, detail] of CHECKS) { + await sleep(pace); + if (!failUntil || n > failUntil) console.log(\` ✓ \${name}\`); + else { failed += 1; console.log(\` ✗ \${name} — \${detail}\`); } +} +await sleep(600); +console.log(failed ? \`\${failed} failed · \${12 - failed} passed (12 checks)\` : "12 passed (12 checks)"); +process.exit(failed ? 1 : 0); +`, + ); +} + +// --------------------------------------------------------------------------- +// Mock LLM: request -> turn descriptor (pure), then per-protocol SSE emitters. +// --------------------------------------------------------------------------- + +/** True when any of the scene's language variants contains `marker` in `flat`. */ +function langOf(flat, pick) { + if (flat.includes(pick(SCENES.en))) return "en"; + if (flat.includes(pick(SCENES.zh))) return "zh"; + return null; +} + +/** + * Decide the turn for one request. `messages` is the raw request message list, + * `toolMarker` the protocol's tool-result fingerprint ('"tool_result"' / '"role":"tool"'). + */ +function decideTurn(messages, toolMarker) { + const flat = JSON.stringify(messages); + const toolResults = flat.split(toolMarker).length - 1; + + // Titles: keyed per conversation (warm-up markers for the two main sessions — the money + // prompts arrive after the title exists — child prompts for the child sessions). + if (flat.includes("concise title")) { + for (const [pick, title] of [ + [(s) => s.agents.warmup, (s) => s.agents.title], + [(s) => s.goal.warmup, (s) => s.goal.title], + [(s) => s.agents.analystPrompt, (s) => s.agents.analystTitle], + [(s) => s.agents.scoutPrompt, (s) => s.agents.scoutTitle], + ]) { + const lang = langOf(flat, pick); + if (lang) return { kind: "title", text: title(SCENES[lang]) }; + } + return { kind: "title", text: "Conversation" }; + } + + // Goal scene: the objective is embedded in every round's [goal] block. + { + const lang = langOf(flat, (s) => s.goal.objective); + if (lang) { + const scene = SCENES[lang].goal; + const rounds = [...flat.matchAll(/round: (\d+)/g)].map((m) => Number(m[1])); + const round = Math.max(1, ...rounds); + let lastRoundIdx = 0; + messages.forEach((m, i) => { + if (JSON.stringify(m).includes("round: ")) lastRoundIdx = i; + }); + const pos = messages + .slice(lastRoundIdx + 1) + .filter((m) => JSON.stringify(m).includes(toolMarker)).length; + const steps = scene.rounds[Math.min(round, scene.rounds.length) - 1]; + const step = { ...steps[Math.min(pos, steps.length - 1)] }; + return { + kind: "turn", + ...step, + usage: { input: 2100, cachePerMsg: 1800, cacheWrite: 850, output: step.exec ? 260 : 540 }, + }; + } + } + + // Agents scene, child sessions first (their context lacks the parent's own prompt). + for (const lang of ["en", "zh"]) { + const scene = SCENES[lang].agents; + if (flat.includes(scene.prompt)) { + // Parent conversation: one turn fans out BOTH children as parallel tool calls (both + // stay inside their yield windows, so both call-graph nodes tick "running"); the + // wrap-up below is only reached if capture overruns the windows. + if (toolResults === 0) { + return { + kind: "turn", + thinking: scene.parentThinking, + subs: [ + { agentId: "data_analyst", prompt: scene.analystPrompt }, + { agentId: "web_scout", prompt: scene.scoutPrompt }, + ], + usage: { input: 420, cachePerMsg: 2100, cacheWrite: 640, output: 190 }, + }; + } + return { + kind: "turn", + text: + lang === "zh" + ? "两路结果已汇总:三次跑分相对七月基线稳步上升,公开数值与本地口径一致。" + : "Both tracks are in: the three runs climb steadily over the July baseline, and the published numbers match the local reports.", + usage: { input: 480, cachePerMsg: 2100, cacheWrite: 640, output: 320 }, + }; + } + if (flat.includes(scene.analystPrompt)) { + if (toolResults === 0) { + return { + kind: "turn", + thinking: scene.analystThinking, + text: scene.analystText, + exec: SCORE_CMD, + usage: { input: 390, cachePerMsg: 1400, cacheWrite: 520, output: 180 }, + }; + } + // Post-capture only (the exec yields to the background at ~60s): stay honest about + // the still-running script rather than claiming completion. + return { + kind: "turn", + text: + lang === "zh" + ? "评分脚本仍在后台运行,完成后我再汇总数字与趋势图。" + : "The scoring script is still running in the background; I'll summarize the numbers and the trend chart once it completes.", + usage: { input: 410, cachePerMsg: 1400, cacheWrite: 520, output: 160 }, + }; + } + if (flat.includes(scene.scoutPrompt)) { + // Held-open drip: the scout keeps writing its verification notes slowly so its call-graph + // node stays "running" (elapsed ticking) for the whole capture window. + return { + kind: "drip", + thinking: scene.scoutThinking, + text: scene.scoutText, + usage: { input: 380, cachePerMsg: 1300, cacheWrite: 500, output: 420 }, + }; + } + if (flat.includes(scene.warmup)) { + return { + kind: "turn", + text: scene.warmupReply, + usage: { input: 350, cachePerMsg: 900, cacheWrite: 380, output: 90 }, + }; + } + if (flat.includes(SCENES[lang].goal.warmup)) { + return { + kind: "turn", + text: SCENES[lang].goal.warmupReply, + usage: { input: 350, cachePerMsg: 900, cacheWrite: 380, output: 100 }, + }; + } + } + + return { + kind: "turn", + text: "OK.", + usage: { input: 300, cachePerMsg: 800, cacheWrite: 300, output: 20 }, + }; +} + +/** Tool calls for a turn: one exec_command, or one run_subagent per `subs` entry. */ +function toolCalls(turn) { + if (turn.exec) return [{ name: "exec_command", json: JSON.stringify({ cmd: turn.exec }) }]; + return (turn.subs ?? []).map(({ agentId, prompt, yieldMs }) => ({ + name: "run_subagent", + json: JSON.stringify({ + agent_id: agentId, + prompt, + ...(yieldMs ? { yield_time_ms: yieldMs } : {}), + }), + })); +} + +/** Guarded SSE write (drip streams outlive aborted clients). */ +function sseWrite(res, chunk) { + if (res.destroyed || res.writableEnded) return false; + res.write(chunk); + return true; +} + +function sse(res, event, data) { + sseWrite(res, `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); +} + +function anthropicReply(res, body) { + const messages = body.messages ?? []; + const turn = decideTurn(messages, '"tool_result"'); + const usage = turn.usage ?? { input: 300, cachePerMsg: 800, cacheWrite: 300, output: 20 }; + + res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache" }); + sse(res, "message_start", { + type: "message_start", + message: { + id: `msg_shot_${Date.now()}`, + type: "message", + role: "assistant", + model: "deepseek-v4-pro", + content: [], + stop_reason: null, + stop_sequence: null, + usage: { + input_tokens: usage.input, + output_tokens: 0, + cache_read_input_tokens: usage.cachePerMsg * messages.length, + cache_creation_input_tokens: usage.cacheWrite, + }, + }, + }); + + const block = (index, start, deltas, extra) => { + sse(res, "content_block_start", { type: "content_block_start", index, content_block: start }); + for (const d of deltas) + sse(res, "content_block_delta", { type: "content_block_delta", index, delta: d }); + if (extra) + sse(res, "content_block_delta", { type: "content_block_delta", index, delta: extra }); + sse(res, "content_block_stop", { type: "content_block_stop", index }); + }; + const finish = (stopReason) => { + sse(res, "message_delta", { + type: "message_delta", + delta: { stop_reason: stopReason, stop_sequence: null }, + usage: { output_tokens: usage.output }, + }); + sse(res, "message_stop", { type: "message_stop" }); + res.end(); + }; + + if (turn.kind === "title") { + block(0, { type: "text", text: "" }, [{ type: "text_delta", text: turn.text }]); + finish("end_turn"); + return; + } + + if (turn.kind === "drip") { + // thinking deltas every ~500ms, then small text pieces every ~2.4s: enough material to + // keep the child visibly streaming (and its node "running") for the whole capture + // window; the task is aborted by the capture flow before the material runs out. + const thinks = turn.thinking.match(/[\s\S]{1,12}/g) ?? []; + const texts = turn.text.match(/[\s\S]{1,8}/g) ?? []; + let step = 0; + sse(res, "content_block_start", { + type: "content_block_start", + index: 0, + content_block: { type: "thinking", thinking: "" }, + }); + const tick = () => { + if (res.destroyed || res.writableEnded) return; + if (step < thinks.length) { + sse(res, "content_block_delta", { + type: "content_block_delta", + index: 0, + delta: { type: "thinking_delta", thinking: thinks[step] }, + }); + step += 1; + setTimeout(tick, 500); + return; + } + if (step === thinks.length) { + sse(res, "content_block_delta", { + type: "content_block_delta", + index: 0, + delta: { type: "signature_delta", signature: "sig_shot" }, + }); + sse(res, "content_block_stop", { type: "content_block_stop", index: 0 }); + sse(res, "content_block_start", { + type: "content_block_start", + index: 1, + content_block: { type: "text", text: "" }, + }); + } + const i = step - thinks.length; + if (i < texts.length) { + sse(res, "content_block_delta", { + type: "content_block_delta", + index: 1, + delta: { type: "text_delta", text: texts[i] }, + }); + step += 1; + setTimeout(tick, 2400); + } + // Out of material: hold the stream open (no message_stop) — capture aborts the task. + }; + tick(); + return; + } + + let index = 0; + if (turn.thinking) { + block( + index++, + { type: "thinking", thinking: "" }, + (turn.thinking.match(/[\s\S]{1,18}/g) ?? []).map((t) => ({ + type: "thinking_delta", + thinking: t, + })), + { type: "signature_delta", signature: "sig_shot" }, + ); + } + if (turn.text) { + block( + index++, + { type: "text", text: "" }, + (turn.text.match(/[\s\S]{1,24}/g) ?? []).map((t) => ({ type: "text_delta", text: t })), + ); + } + const calls = toolCalls(turn); + if (calls.length > 0) { + for (const [i, { name, json }] of calls.entries()) { + block( + index++, + { type: "tool_use", id: `toolu_shot_${Date.now()}_${i}`, name, input: {} }, + (json.match(/[\s\S]{1,32}/g) ?? []).map((partial_json) => ({ + type: "input_json_delta", + partial_json, + })), + ); + } + finish("tool_use"); + } else { + finish("end_turn"); + } +} + +function openaiReply(res, body) { + const messages = body.messages ?? []; + const turn = decideTurn(messages, '"role":"tool"'); + const usage = turn.usage ?? { input: 300, cachePerMsg: 800, cacheWrite: 300, output: 20 }; + const hit = usage.cachePerMsg * messages.length; + const prompt = hit + usage.input + usage.cacheWrite; + + res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache" }); + const chunk = (delta, finishReason = null, withUsage) => { + const payload = { + id: "chatcmpl-shot", + object: "chat.completion.chunk", + created: Math.floor(Date.now() / 1000), + model: "deepseek-v4-pro", + choices: [{ index: 0, delta, finish_reason: finishReason }], + }; + if (withUsage) { + payload.usage = { + prompt_tokens: prompt, + completion_tokens: usage.output, + total_tokens: prompt + usage.output, + prompt_cache_hit_tokens: hit, + prompt_cache_miss_tokens: usage.input + usage.cacheWrite, + }; + } + sseWrite(res, `data: ${JSON.stringify(payload)}\n\n`); + }; + const done = (reason) => { + chunk({}, reason, true); + sseWrite(res, "data: [DONE]\n\n"); + res.end(); + }; + + chunk({ role: "assistant" }); + if (turn.kind === "title") { + chunk({ content: turn.text }); + done("stop"); + return; + } + + if (turn.kind === "drip") { + const thinks = turn.thinking.match(/[\s\S]{1,12}/g) ?? []; + const texts = turn.text.match(/[\s\S]{1,8}/g) ?? []; + let step = 0; + const tick = () => { + if (res.destroyed || res.writableEnded) return; + if (step < thinks.length) { + chunk({ reasoning_content: thinks[step] }); + step += 1; + setTimeout(tick, 500); + return; + } + const i = step - thinks.length; + if (i < texts.length) { + chunk({ content: texts[i] }); + step += 1; + setTimeout(tick, 2400); + } + // Hold open once out of material (no finish chunk). + }; + tick(); + return; + } + + if (turn.thinking) + for (const t of turn.thinking.match(/[\s\S]{1,18}/g) ?? []) chunk({ reasoning_content: t }); + if (turn.text) for (const t of turn.text.match(/[\s\S]{1,24}/g) ?? []) chunk({ content: t }); + const calls = toolCalls(turn); + if (calls.length > 0) { + for (const [i, { name, json }] of calls.entries()) { + chunk({ + tool_calls: [ + { + index: i, + id: `call_shot_${Date.now()}_${i}`, + type: "function", + function: { name, arguments: "" }, + }, + ], + }); + for (const part of json.match(/[\s\S]{1,32}/g) ?? []) { + chunk({ tool_calls: [{ index: i, function: { arguments: part } }] }); + } + } + done("tool_calls"); + } else { + done("stop"); + } +} + +function startMock() { + const server = http.createServer((req, res) => { + if (req.method !== "POST") { + res.writeHead(404).end(); + return; + } + let body = ""; + req.on("data", (c) => (body += c)); + req.on("end", () => { + let json = {}; + try { + json = JSON.parse(body); + } catch {} + try { + if (req.url?.includes("chat/completions")) return openaiReply(res, json); + if (req.url?.includes("messages")) return anthropicReply(res, json); + console.log(`[mock] unexpected path ${req.url}`); + res.writeHead(404).end(); + } catch (err) { + console.error("[mock] reply error:", err); + try { + res.end(); + } catch {} + } + }); + }); + return new Promise((resolve) => server.listen(MOCK_PORT, "127.0.0.1", () => resolve(server))); +} + +// --------------------------------------------------------------------------- +// Server + API helpers (same shape as capture-shots.mjs). +// --------------------------------------------------------------------------- + +async function waitFor(url, tries = 60) { + for (let i = 0; i < tries; i++) { + try { + const res = await fetch(url); + if (res.ok) return; + } catch {} + await new Promise((r) => setTimeout(r, 500)); + } + throw new Error(`server not ready: ${url}`); +} + +async function api(cookie, method, url, body) { + const res = await fetch(`${BASE}${url}`, { + method, + headers: { "content-type": "application/json", ...(cookie ? { cookie } : {}) }, + ...(body ? { body: JSON.stringify(body) } : {}), + }); + if (!res.ok) throw new Error(`${method} ${url} -> ${res.status} ${await res.text()}`); + return { json: await res.json().catch(() => ({})), setCookie: res.headers.get("set-cookie") }; +} + +async function login(userId, password) { + const { setCookie } = await api(null, "POST", "/api/auth/login", { userId, password }); + if (!setCookie) throw new Error("no session cookie from login"); + return setCookie.split(";")[0]; +} + +/** Provision a per-language user with the mock model + named Agents; password rotated once. */ +async function provisionUser(adminCookie, lang) { + const { userId, agents } = USERS[lang]; + const initial = `${userId}12345`; + await api(adminCookie, "POST", "/api/admin/users", { userId, password: initial }).catch((e) => { + if (!String(e).includes("409")) throw e; + }); + let password = initial; + let cookie = await login(userId, initial); + try { + await api(cookie, "PUT", "/api/me/password", { + oldPassword: initial, + newPassword: `penguin-${userId}-2026`, + }); + password = `penguin-${userId}-2026`; + } catch {} + cookie = await login(userId, password); + + const projects = (await api(cookie, "GET", "/api/projects")).json; + const projectId = projects.projects[0].projectId; + await api(cookie, "PUT", `/api/projects/${projectId}/models`, { + defaultModel: { provider: "deepseek", modelId: "deepseek-v4-pro" }, + models: [ + { + provider: "deepseek", + modelId: "deepseek-v4-pro", + apiKey: "sk-demo", + baseUrl: MOCK, + contextWindow: 1000000, + pricing: { cacheRead: 0.003571, cacheWrite: 0.428571, output: 0.857143 }, + }, + ], + }); + for (const agent of agents) { + await api(cookie, "POST", `/api/projects/${projectId}/agents`, agent).catch((e) => { + if (!String(e).includes("409")) throw e; + }); + } + return { cookie, password, projectId, userId }; +} + +/** Create a session bound to the mock model in the given Workspace dir. */ +async function createSession(user, workspace) { + const sess = ( + await api( + user.cookie, + "POST", + `/api/projects/${user.projectId}/agents/default_agent/sessions`, + { + provider: "deepseek", + modelId: "deepseek-v4-pro", + approvalMode: "allow-all", + workspace, + }, + ) + ).json; + return sess.session.sessionId; +} + +/** Run one quick task so the session gets its generated title before the money shot. */ +async function warmup(user, sessionId, text, doneMarker, page) { + await api(user.cookie, "POST", `/api/sessions/${sessionId}/tasks`, { + input: [{ type: "text", text }], + }); + await page.getByText(doneMarker).first().waitFor({ timeout: 30000 }); + await page.waitForTimeout(1500); // title generation is post-Task; give it a beat +} + +// --------------------------------------------------------------------------- +// Main. +// --------------------------------------------------------------------------- + +const dataRoot = mkdtempSync(path.join(os.tmpdir(), "penguin-blog013-")); +mkdirSync(OUT_DIR, { recursive: true }); + +const mock = await startMock(); +console.log(`[blog-013] mock LLM on ${MOCK}`); + +const srv = spawn("node", [path.join(ROOT, "packages/server/dist/index.js")], { + env: { + ...process.env, + PENGUIN_HOME: path.join(dataRoot, "home"), + PENGUIN_WEB_DB: path.join(dataRoot, "web.db"), + PENGUIN_WEB_DIST: path.join(ROOT, "packages/web/dist"), + PORT: String(SRV_PORT), + HOST: "127.0.0.1", + }, + stdio: ["ignore", "pipe", "pipe"], +}); +srv.stderr.on("data", (d) => process.stderr.write(`[srv!] ${d}`)); +const cleanup = () => { + try { + srv.kill(); + } catch {} + try { + mock.close(); + } catch {} +}; +process.on("exit", cleanup); + +try { + await waitFor(`${BASE}/`); + console.log(`[blog-013] server ready on ${BASE}`); + + const adminCookie = await login("admin", "penguin-2026"); + const browser = await chromium.launch(); + + // WebP encoder: Chromium re-encodes the PNG screenshot via canvas (capture-shots convention). + const encoderPage = await browser.newPage(); + async function saveWebp(pngBuffer, fileName) { + const dataUrl = await encoderPage.evaluate(async (b64) => { + const img = new Image(); + img.src = `data:image/png;base64,${b64}`; + await img.decode(); + const canvas = document.createElement("canvas"); + canvas.width = img.width; + canvas.height = img.height; + canvas.getContext("2d").drawImage(img, 0, 0); + return canvas.toDataURL("image/webp", 0.82); + }, pngBuffer.toString("base64")); + writeFileSync(path.join(OUT_DIR, fileName), Buffer.from(dataUrl.split(",")[1], "base64")); + console.log(`[blog-013] ${fileName}`); + } + + /** New logged-in context+page at 1280x900 @1.5x with theme/lang/panel-width preset. */ + async function openPage(user, lang, theme, url) { + const context = await browser.newContext({ + viewport: { width: 1280, height: 900 }, + deviceScaleFactor: 1.5, + locale: lang === "zh" ? "zh-CN" : "en-US", + }); + await context.addInitScript( + ([t, l]) => { + localStorage.setItem("penguin.theme", t); + localStorage.setItem("penguin.lang", l); + localStorage.setItem("penguin.subagentsPanelWidth", "430"); + }, + [theme, lang], + ); + const page = await context.newPage(); + globalThis.__page = page; // last opened page, for the failure dump below + await page.goto(`${BASE}/login`); + const loginRes = await page.request.post(`${BASE}/api/auth/login`, { + data: { userId: user.userId, password: user.password }, + }); + if (!loginRes.ok()) throw new Error(`browser login failed: ${loginRes.status()}`); + await page.goto(url); + return { context, page }; + } + + /** + * Screenshot BOTH themes concurrently: exec_command / run_subagent yield to the + * background at ~60s (DEFAULT_EXEC_YIELD_MS and the tools' timeouts), and a + * backgrounded child renders as "done" — everything is visibly live only inside that + * first minute, so the two shots must land inside the same window, not sequentially. + */ + async function captureBothThemes(user, lang, url, fileBase, prep) { + const pages = await Promise.all( + ["light", "dark"].map((theme) => openPage(user, lang, theme, url)), + ); + try { + await Promise.all( + pages.map(async ({ page }, i) => { + try { + await prep(page); + } catch (err) { + // Dump THIS page before the contexts close (the global catch fires too late). + const dump = process.env.SHOT_DEBUG_DIR || os.tmpdir(); + const tag = `${fileBase}-${lang}-${["light", "dark"][i]}`; + await page + .screenshot() + .then((buf) => writeFileSync(path.join(dump, `fail-${tag}.png`), buf)) + .catch(() => {}); + await page + .evaluate(() => document.body.innerText) + .then((text) => writeFileSync(path.join(dump, `fail-${tag}.txt`), text)) + .catch(() => {}); + throw err; + } + await page.waitForTimeout(2000); + await saveWebp( + await page.screenshot(), + `${fileBase}-${lang}-${["light", "dark"][i]}.webp`, + ); + }), + ); + } finally { + for (const { context } of pages) await context.close(); + } + } + + for (const lang of ["zh", "en"]) { + const user = await provisionUser(adminCookie, lang); + const scene = SCENES[lang]; + const ui = USERS[lang]; + const wsRoot = path.join(dataRoot, "ws", lang); + + // ---- Scene 1: agents panel -------------------------------------------------------- + { + const ws = path.join(wsRoot, "benchmark-review"); + mkdirSync(ws, { recursive: true }); + stageBenchWorkspace(ws); + const sessionId = await createSession(user, ws); + + // Drive once: warm-up for the title, then the fan-out prompt via the composer. + const driver = await openPage(user, lang, "light", `${BASE}/chat/${sessionId}`); + await warmup(user, sessionId, scene.agents.warmup, "score-runs", driver.page); + const input = driver.page.getByPlaceholder(/输入消息|Type a message/); + await input.waitFor({ timeout: 20000 }); + await input.fill(scene.agents.prompt); + await driver.page.getByRole("button", { name: /发送|Send/ }).click(); + // Both children spawned: their bar rows carry the resolved agent names. + await driver.page.getByText(ui.agents[0].name).first().waitFor({ timeout: 60000 }); + await driver.page.getByText(ui.agents[1].name).first().waitFor({ timeout: 60000 }); + await driver.context.close(); + + await captureBothThemes( + user, + lang, + `${BASE}/chat/${sessionId}`, + "agents-panel", + async (page) => { + await page.getByText(ui.agents[1].name).first().waitFor({ timeout: 30000 }); + // Give the replayed stream's auto-open a beat, then ensure the panel is open — + // keyed on the topology label so an already-open panel is never toggled closed — + // and focus the analyst's conversation. + await page.waitForTimeout(1200); + if ( + !(await page + .getByText(ui.topologyLabel) + .first() + .isVisible() + .catch(() => false)) + ) { + await page.getByRole("button", { name: ui.panelTitle }).first().click(); + } + const node = page.getByRole("button", { name: ui.analystNode, exact: true }); + await node.waitFor({ timeout: 20000 }); + await node.click(); + // Tool cards render as collapsed rows: expand the analyst's running exec card so + // the command and its streaming output are in frame (the only exec_command row on + // the page — the parent conversation holds run_subagent rows only). + const execRow = page.getByText("exec_command").last(); + await execRow.waitFor({ timeout: 30000 }); + await execRow.click(); + await page.getByText("--runs 41,42,43").first().waitFor({ timeout: 15000 }); + // Let scoring lines and node elapsed accumulate, still well inside the ~60s window. + await page.waitForTimeout(12000); + }, + ); + await api(user.cookie, "POST", `/api/sessions/${sessionId}/abort`, {}).catch(() => {}); + } + + // ---- Scene 2: goal mode ----------------------------------------------------------- + { + const ws = path.join(wsRoot, "penguin-table"); + mkdirSync(ws, { recursive: true }); + stageTableWorkspace(ws); + const sessionId = await createSession(user, ws); + + const driver = await openPage(user, lang, "light", `${BASE}/chat/${sessionId}`); + await warmup(user, sessionId, scene.goal.warmup, "run-checks", driver.page); + // Start the goal (the composer's goal chip posts exactly this request shape). + await api(user.cookie, "POST", `/api/sessions/${sessionId}/tasks`, { + input: [{ type: "text", text: scene.goal.objective }], + goal: { budget: 500000 }, + }); + await driver.context.close(); + + // Rounds 1-2 run and fix for real; round 3's slow verification run is the window. + await captureBothThemes( + user, + lang, + `${BASE}/chat/${sessionId}`, + "goal-mode", + async (page) => { + await page.getByText(ui.round3).first().waitFor({ timeout: 180000 }); + // Round 3's own lead-in text (unique to this round), then expand the running check + // run (rounds 1-2 are collapsed groups, so theirs are unmounted) and let a few + // green checks stream into the card — still inside the ~60s exec window. + await page.getByText(ui.round3Text).first().waitFor({ timeout: 60000 }); + const checkRow = page.getByText("exec_command").last(); + await checkRow.waitFor({ timeout: 30000 }); + await checkRow.click(); + await page.getByText("column widths · ascii").first().waitFor({ timeout: 30000 }); + await page.waitForTimeout(14000); + }, + ); + await api(user.cookie, "POST", `/api/sessions/${sessionId}/abort`, {}).catch(() => {}); + } + } + + await browser.close(); + console.log(`[blog-013] done -> ${OUT_DIR}`); + process.exit(0); +} catch (err) { + console.error("[blog-013] FAILED:", err); + // Failure dump: the last page's screenshot plus every button's accessible naming hints. + try { + const page = globalThis.__page; + if (page && !page.isClosed()) { + const dump = process.env.SHOT_DEBUG_DIR || os.tmpdir(); + writeFileSync(path.join(dump, "blog013-failure.png"), await page.screenshot()); + const buttons = await page.evaluate(() => + [...document.querySelectorAll("button")].map( + (b) => + b.getAttribute("aria-label") || b.getAttribute("title") || b.textContent?.slice(0, 40), + ), + ); + console.error("[blog-013] page buttons:", JSON.stringify(buttons)); + } + } catch {} + process.exit(1); +} diff --git a/packages/landing/test/blog.test.ts b/packages/landing/test/blog.test.ts index 918c528..403d6e1 100644 --- a/packages/landing/test/blog.test.ts +++ b/packages/landing/test/blog.test.ts @@ -96,7 +96,7 @@ describe("frontmatter mapping (author / pinned / category)", () => { it("reads the pinned flag and sorts the pinned post first", () => { for (const locale of ["en", "zh"] as const) { const posts = postsFor(locale); - expect(posts.length).toBe(11); + expect(posts.length).toBe(12); // The launch post stays the single pinned post; newer posts sort under it by date. expect(posts.filter((p) => p.pinned).map((p) => p.slug)).toEqual([ "introducing-penguinharness", @@ -120,6 +120,7 @@ describe("frontmatter mapping (author / pinned / category)", () => { it("filters by the news category, newest first", () => { expect(postsFor("en", "news").map((p) => p.slug)).toEqual([ "introducing-penguinharness", + "penguinharness-0-1-4", "free-models-in-penguin-harness", "gemini-3-6-in-penguinharness", "fireworks-credits-amd",