From 5f2f38f422979b7caa409e56cad1ff648265386b Mon Sep 17 00:00:00 2001 From: Yaowei Zheng Date: Wed, 29 Jul 2026 23:47:53 +0800 Subject: [PATCH] docs(changelog): record the batch since v0.1.4 (#101) Co-authored-by: Claude Opus 5 (1M context) Co-authored-by: laodouuu <1257029425@qq.com> --- .../unreleased/2026-07-27-sites-and-blog.md | 7 +++ .../2026-07-27-windows-bundled-shell.md | 29 +++++++++++ changelog/unreleased/2026-07-28-web-app.md | 48 +++++++++++++++++++ .../2026-07-29-cost-center-errors.md | 27 +++++++++++ .../2026-07-29-harness-env-and-dev-ports.md | 22 +++++---- .../2026-07-29-llm-request-lifecycle.md | 23 +++++++++ changelog/unreleased/README.md | 13 ++++- 7 files changed, 160 insertions(+), 9 deletions(-) create mode 100644 changelog/unreleased/2026-07-27-sites-and-blog.md create mode 100644 changelog/unreleased/2026-07-27-windows-bundled-shell.md create mode 100644 changelog/unreleased/2026-07-28-web-app.md create mode 100644 changelog/unreleased/2026-07-29-cost-center-errors.md create mode 100644 changelog/unreleased/2026-07-29-llm-request-lifecycle.md diff --git a/changelog/unreleased/2026-07-27-sites-and-blog.md b/changelog/unreleased/2026-07-27-sites-and-blog.md new file mode 100644 index 0000000..7719396 --- /dev/null +++ b/changelog/unreleased/2026-07-27-sites-and-blog.md @@ -0,0 +1,7 @@ +# Sites: the 0.1.4 release post + +A release post for **0.1.4** in both languages, covering the batch's three themes — Windows becoming a first-class install, goal mode looping Tasks until an objective is actually reached rather than merely replied to, and the agents panel turning subagent fan-outs into a live call graph. + +The post is deliberate about limits rather than quiet on them: the Windows package is x64-only for now, `input_command`'s Ctrl-C hard-kills the command tree instead of interrupting the foreground command, and upgrading on Windows means re-running the installer because in-place `penguin update` still refuses there. + +A capture script for the post's screenshots joins the existing ones, staging its output in the gitignored blog-assets directory for upload to the sibling community repo rather than committing images into this clone. diff --git a/changelog/unreleased/2026-07-27-windows-bundled-shell.md b/changelog/unreleased/2026-07-27-windows-bundled-shell.md new file mode 100644 index 0000000..34af44d --- /dev/null +++ b/changelog/unreleased/2026-07-27-windows-bundled-shell.md @@ -0,0 +1,29 @@ +# Windows: the package bundles its own bash, so the Agent's shell no longer depends on the machine + +`penguin-win32-x64.zip` now ships **MinGit** under `git/`, and `exec_command` uses it when the machine has no Git for Windows of its own. + +## Why + +The Windows shell was whatever happened to be installed: `bash` if the user had Git for Windows, otherwise PowerShell. That made the same Agent running the same Skill behave differently on two Windows machines, and the degradation was silent — a Skill written for a POSIX shell does not fail loudly under PowerShell, it fails strangely. The sharpest case: `curl -fsSL ` under Windows PowerShell 5.1 resolves the built-in `curl` → `Invoke-WebRequest` alias and returns a cmdlet parameter-binding error, which a model recovers from far less easily than "command not found". + +## What is bundled + +MinGit's `usr/bin/sh.exe` **is GNU bash** — the minimal Git for Windows build installs bash under the name `sh`. So the bundle costs 37MB compressed (~91MB installed) for a real bash, roughly sixty core utilities, and `git.exe`, rather than the ~350MB extracted that full PortableGit would have needed for complete parity. + +No PATH plumbing is involved. MinGit's `etc/profile` defaults to `MSYS2_PATH_TYPE=inherit`, so a login shell (`-lc`, unchanged) yields `/mingw64/bin:/usr/local/bin:/usr/bin:/bin:` — bundled tools and git first, the Windows PATH still behind them. That is why MinGit carrying no `curl` or `tar` does not matter: System32's `curl.exe` and `tar.exe` keep resolving, and inside bash they are the real binaries rather than PowerShell aliases. + +## Resolution order + +`PENGUIN_SHELL` → `bash` on PATH → **bundled** → `pwsh` → `powershell`. + +The bundle sits deliberately *after* the PATH probe: a user's own Git for Windows carries the complete MSYS userland (curl, tar, less, perl …) while MinGit carries about sixty tools, so when both exist theirs is the better shell. The bundle is the floor, not the preference. `pwsh` / `powershell` stay reachable for npm installs, which bundle nothing. The resolved shell is reported to the model as `bash`, since that is what it is and what the Skill ecosystem targets. + +The installer treats `git/` like the other payload directories — replaced on upgrade, never touching the data directory — and the launcher shims advertise the path as `PENGUIN_BUNDLED_SHELL`. + +## Licensing + +MinGit is GPLv2, so a new root `THIRD-PARTY-NOTICES.md` records both bundled components — the Node runtime and MinGit — with where each one's license text sits inside the bundle and where its corresponding source lives. The Git for Windows release is pinned to one exact tag so the notice names a single version, and the bundled bytes are the unmodified published asset. + +## Cost, stated plainly + +The Windows download roughly doubles (~65MB → ~100MB) and the install grows about 91MB; `Expand-Archive` on PowerShell 5.1 has 368 more files to unpack, so a fresh install is noticeably slower. The bundle also means a *hybrid* environment — MSYS coreutils beside native node and git — not a POSIX one, so MSYS path translation remains a thing to watch. `MSYS_NO_PATHCONV` / `MSYS2_ARG_CONV_EXCL` are deliberately left unset: matching real Git Bash semantics is the least surprising default, and diverging would make the bundled shell behave unlike the PATH one. diff --git a/changelog/unreleased/2026-07-28-web-app.md b/changelog/unreleased/2026-07-28-web-app.md new file mode 100644 index 0000000..4238406 --- /dev/null +++ b/changelog/unreleased/2026-07-28-web-app.md @@ -0,0 +1,48 @@ +# Web App: config forms stop inviting saved logins, panels and pages stop growing scrollbars, blank replies and duplicated outcomes disappear, and Benchmark costs follow the selected currency + +Four rendering and input-handling fixes: the browser stops filling account credentials into model configuration, a closed docked panel stops painting a stray 1px divider next to the open one, a provider-signature message that carries no text stops drawing an empty reply bubble, and Benchmark costs use the display currency selected in settings. + +## Browser autofill no longer drops account credentials into model configuration + +Opening the model dialog could hand the browser's saved login straight into the configuration: the account password landed in the API-key box, the username in whichever field sat above it. Two causes, both of them the browser doing what it thinks is helpful: + +- The dialog's fields belong to no `
` element — the app has exactly one real form, on the login page — so the browser groups the page's unowned fields into a synthetic one and picks a "username" box on its own. +- The API-key box already declared `autocomplete="off"`, which **Chrome and Safari ignore on a password field**. They offer the saved login anyway. + +Every shared form control now opts out unless its caller declares a real credential role. Fields go out as `autocomplete="off"`, and an opted-out *secret* field as `autocomplete="new-password"` — the one value password managers read as "not the account password" — with the manager-extension opt-outs (1Password, LastPass, Bitwarden, Dashlane) alongside, since those read their own attributes rather than `autocomplete`. Nothing in the app's forms wanted autofill in the first place: they hold API keys, model ids, URLs, directories and prices. + +The login page and the password dialogs are untouched — they declare `username` / `current-password` / `new-password`, and a declared role passes through as the caller's decision, so saving and filling a real password keeps working. + +The policy lives in the shared `Input` / `Textarea` rather than at each call site, so it also covers the pages that were never reported (Agents, skills, schedules, the vault); the handful of raw inputs that bypass those components — the model and skills menu search boxes, the goal budget, the Workspace path editor — carry the same opt-out explicitly. + +## The closed docked panel no longer paints a stray divider + +Opening the Workspace files panel showed what looked like a second, empty panel wedged between the conversation and the files: a hairline, a gap, then the real divider. + +Both docked panels (Workspace files, agents) stay mounted while closed — the width transition needs the node — collapsed to a zero-width clipping window. Under border-box sizing their left border still painted its 1px there, so the closed agents panel left a line stranded beside the open one, with the open panel's resize gutter as the gap between them. With both panels closed, the two leftover lines stacked at the window's right edge. + +The divider now belongs to the open state only, so a closed panel takes no width at all and exactly one divider separates the conversation from whichever panel is open. + +## No more empty reply bubble after a thinking segment + +An empty `assistant:` bubble sometimes appeared right after the model finished thinking — no text, just a blank block taking up a line. + +It was not a stray empty string. Core deliberately emits a **complete text or thinking message with an empty body** when a provider attaches an opaque `fidelity` payload to an otherwise empty part — a Gemini `thoughtSignature` on a text part, a GPT-5 `fidelity.phase` segment marker, GPT-5's encrypted reasoning on the thinking side. That message has to exist, or the signature is lost from history and the next request goes out without it. It simply has nothing to display, and it lands at the thinking→text boundary, which is why the gap showed up after thinking and only on some providers and some turns. + +A blank body now produces no item at all, on both paths that can deliver one. When the message arrives on its own — a history rebuild, or joining a stream mid-flight — it is skipped rather than appended. When a whitespace-only segment actually streamed (core opens a text segment on the first *truthy* delta, and `"\n\n"` is truthy), the in-flight fragment is discarded rather than settled in place, so a live view and a reloaded page render the same thing instead of the blank bubble disappearing on refresh. Whitespace-only counts as empty throughout, matching what the reply-copy path already treated as nothing. + +## Benchmark costs follow the selected display currency + +Benchmark costs used to stay in USD after the display currency was switched to CNY. The page called the shared money formatter without the selected currency, so its USD default remained active while the model library, Cost center, chat, and Trace views followed the setting. + +The cost trend axis and tooltip, evaluation totals, case totals, and individual run costs now all receive the selected display currency. Score and duration formatting are unchanged. + +## The tool card row states an outcome once + +The collapsed tool card said the same thing twice: the status icon on the left already distinguishes done from failed from aborted, and a pill to the right of the duration spelled the word out again. Both pills are gone — the call's and the output's — leaving the icon, with its title and accessible name, as the single source. + +## A page can no longer grow the document into a second scrollbar + +With a second Agent below a long session list, the Traces page and the Agent settings page grew the **document**: a second scrollbar appeared, and dragging it pulled the whole app shell up, leaving blank space at the top. Both import controls keep their file input visually hidden but Tab-focusable, which means `position: absolute`, and neither scroller was a containing block — so nothing clipped those boxes, and their position past the fold became document-level overflow. One Agent hid it entirely: that control sits at the top of the tree. + +The rule now lives in one place instead of in each scroller — every scrollable container is its own containing block, declared once in the stylesheet — and the visually-hidden file input became a shared control carrying its own containing block wherever it is dropped. An end-to-end sweep guards the invariant across the pages: with two Agents and a list long enough to push the second past the fold, no page may grow the document. diff --git a/changelog/unreleased/2026-07-29-cost-center-errors.md b/changelog/unreleased/2026-07-29-cost-center-errors.md new file mode 100644 index 0000000..443800b --- /dev/null +++ b/changelog/unreleased/2026-07-29-cost-center-errors.md @@ -0,0 +1,27 @@ +# Cost center: the error table pages back, reads shorter, and stops logging ordinary command exits + +Three changes to the error panel, which had become hard to use for the thing it exists for — finding the error that actually matters. + +## The table pages back + +It showed the newest 20 rows with no way further, so an error from an hour ago was simply unreachable. + +A new endpoint returns any window of the history newest-first, with the filtered row count alongside it so the pager knows where the end is. Page 0 costs nothing extra — it is the batch the dashboard response already carries — so only stepping back issues a request. + +The endpoint takes the dashboard's **date and agent filter only**. The model filter is deliberately not accepted: it never applied to errors in the first place (HTTP and process errors have no model dimension), so honouring it here would imply a narrowing that the summary above the table does not do. Admin-only visibility of unattributed errors carries over unchanged — a regular member paging back must not start seeing another tenant's login failures. + +The pager sits outside the scroll box so it stays reachable without scrolling to the bottom of the rows, and appears only when there is more than one page. A page that fails to load shows its message and keeps the rows already on screen rather than blanking the table. + +## Shorter source labels + +A row's source now reads `[env] tool_failed:exec_command` instead of `environment · tool_failed:exec_command`. Only `environment` needed shortening — every other source (`http`, `llm`, `session`, `usage`, `title`, `subagent`, `process`, `schedule`) already reads at a glance — so there is one abbreviation rather than a scheme. The "most common error code" statistic uses the same shape, so the two agree. + +## A non-zero exit from a command tool is no longer an error + +Core mapped every non-zero exit code to a failure, and every failure was recorded. But a non-zero exit is how shell commands return information, not a fault: `grep` exits 1 when nothing matches, `test -f` when the file is absent, `diff` when files differ. Recording those turned the error table into a log of ordinary Agent work, burying real errors and consuming both the row cap and the deduplication window that exist to protect them. The Agent already sees the exit code and adjusts; nothing in it needs a human. + +Both command tools are covered, not just the obvious one: the tool that polls a backgrounded command reaches the same exit-code mapping, so excluding only the foreground path would record the same command's non-zero exit depending on how it happened to finish. + +What is dropped is keyed on the note the tool appends (`[exit code: N]`), not on the tool's name — the same `failed` status also covers faults no Agent can adjust its way out of: a command killed by a signal (an OOM-killed build, a segfaulting test binary), a spawn failure (nonexistent working directory, EMFILE, an unresolvable shell), a tool timeout, or a missing command-session manager. Those still record, and they are exactly the "needs a human" cases the table exists for. + +The exclusion happens where errors are captured rather than where they are queried, so the noise never reaches the table, the row cap, or the deduplication key. The paged route's tenant isolation is now covered by tests as well: an outsider gets a 404, a traversal id gets a 404, and a member paging through offsets never reaches another tenant's rows even with foreign records interleaved into the same pages. diff --git a/changelog/unreleased/2026-07-29-harness-env-and-dev-ports.md b/changelog/unreleased/2026-07-29-harness-env-and-dev-ports.md index e4606b0..cf15e9c 100644 --- a/changelog/unreleased/2026-07-29-harness-env-and-dev-ports.md +++ b/changelog/unreleased/2026-07-29-harness-env-and-dev-ports.md @@ -1,15 +1,21 @@ -# Tooling: harness environment variables no longer leak into Agent commands +# The harness's own port stops colliding: out of the Agent's environment, off the dev server -Agent-spawned commands now start from a host environment with PenguinHarness-owned server variables removed, and the development backend moves off the installed server's default port so local app work is less likely to talk to the wrong process. +Two places where PenguinHarness's listen port reached somewhere it should not have. -## Child command environment +## `PORT` no longer leaks into commands the Agent runs -`exec_command` and `input_command` build their child process environment through the command session manager. That path now deletes `PORT`, `HOST`, `PENGUIN_CLI_ENTRY`, and `PENGUIN_WEB_DIST` from the inherited host environment before applying the Agent vault and command-hardening defaults. +`penguin web` writes `PORT` and `HOST` into its own `process.env` as the channel to the server module, and the command Environment built child environments straight from `process.env`. Every `exec_command` therefore inherited `PORT=7364` — and `npm run dev`, Vite, Next and most Express templates read `PORT`, so an Agent asked to start a dev server tried to bind **the harness's own port** instead of picking one. -The change keeps the harness's own listen address and internal launch paths out of programs an Agent starts. A generated Vite, Next.js, Express, or similar dev server can therefore choose its own port instead of inheriting the port that PenguinHarness itself is using. When an Agent really does need to force a command's `PORT`, the vault still applies after the host strip, so that explicit per-Agent value wins. +The child environment now drops `PORT`, `HOST`, and the internal `PENGUIN_CLI_ENTRY` / `PENGUIN_WEB_DIST`. Keys are removed rather than blanked, since a program may test `PORT` for presence rather than value. Stripping applies even when the value came from the user's shell (`PORT=3000 penguin web`): it still means "the port PenguinHarness is on", which is precisely the port a spawned server must avoid. The Agent's vault is applied afterwards, so setting `PORT` there deliberately still reaches commands. -## Development ports +`PENGUIN_HOME` and the other user-facing `PENGUIN_*` settings are deliberately kept — an Agent working on PenguinHarness itself may legitimately want the same data root, and that is a configuration decision rather than a leak. -The repository's development backend now defaults to `7368`, leaving the installed server and Web UI on the packaged default `7364`. The Web package's Vite proxy points at that development backend by default, while `PENGUIN_API_PROXY` can still override the whole proxy target and `PORT` can still move the backend/proxy pair together for local experiments. +## The development backend moves off 7364 -The port allocation table in core documents the local development ports alongside the installed default, and the development docs now call out the split so developers can tell which process their browser is reaching. +`pnpm dev:server` bound the same port as an installed `penguin web`, and the two routinely run at once. Either the dev server failed to bind, or — quieter and worse — the Vite proxy talked to the **installed** server instead of the one being worked on, so code changes appeared to do nothing. + +The development backend now listens on **7368**; 7365, 7366 and 7367 are already the web, landing and docs dev servers. `pnpm dev:web` is unchanged at 7365 and remains the address to open — only what it proxies to has moved. The full port allocation is documented in `core/internal/ports.ts`, which is where a reader looks, even though the dev ports themselves have to be literals in vite configs and package.json scripts. + +Overrides still work and now move together: the dev scripts apply `PORT` only when it is unset, and the Vite proxy reads `PORT` with 7368 as its fallback, so changing the backend port carries the proxy with it instead of leaving it pointed at the old one. + +The dev *data root* was separated from the installed one for exactly this reason; this finishes the job for the port. diff --git a/changelog/unreleased/2026-07-29-llm-request-lifecycle.md b/changelog/unreleased/2026-07-29-llm-request-lifecycle.md new file mode 100644 index 0000000..dacd490 --- /dev/null +++ b/changelog/unreleased/2026-07-29-llm-request-lifecycle.md @@ -0,0 +1,23 @@ +# Core: what a failed request does next + +## Every failure but a rejected credential now retries + +The engine used to reconnect on `timeout` and `malformed` only. `failed` — the bucket for "the classifier did not judge this transient" — ended the turn. + +The trouble is what that classifier is: an allowlist of known error codes, HTTP statuses and message vocabulary. A gateway that words a transient fault its own way (`Upstream HTTP/2 stream failed`, say) falls straight through it and lands in `failed`. Retrying a genuinely permanent error costs the backoff ladder and then ends the same way; aborting a transient one destroys the turn. So the policy widened: **`failed` retries too, and `auth` is the only LLM status that stops the run** — a rejected credential cannot be retried into working. This changes the policy, not the taxonomy: a `failed` request is still reported as `failed` on its `request_end` and in the Cost center, not relabelled a timeout. + +The retry is visible, because a retry the user cannot see is a stalled session with no explanation and no way out: `failed` renders exactly like the other two — the Web App's countdown with "retry now" and "give up", the CLI's `[retry]` line — and the attempt number keeps counting across a mixed ladder instead of restarting at #1. + +Compaction retries the same set. It used to stop on `failed` while the turn loop retried it, which had the trade backwards — a compaction that gives up keeps the full context, so the next request re-triggers it against the same wall with less headroom. What stays narrower is the budget: compaction runs on its own shorter cap (3 retries) rather than the turn loop's 5. + +Observability follows the policy. A `failed` the ladder recovered from is no longer filed as an operator-facing incident — it records as expected under `llm_failed_retried`, while an exhausted ladder stays unexpected under `llm_failed`. Authentication failures move to their own `llm_auth` code: they used to share the `llm_failed` deduplication bucket, so a genuine credential failure landing just behind a recovered blip was dropped outright. Trace segmentation carried a second copy of the old rule and read a retried `failed` as a turn boundary, smearing one turn's Tokens, duration and TPS across two Tasks; it now follows the engine's loop. + +## A provider that ignores Stop can no longer wedge a Session + +Pressing Stop mid-request could leave a Session running forever — no way to send, no way to compact, and a second Stop did nothing. Short of restarting, the Session was finished. Reported against Kimi. + +The request loop already guarded half of this. Interrupting while the loop sits suspended at a `yield` — the common case, where the engine is blocked waiting for a tool approval — is caught by the abort check it runs before pulling from upstream again. Interrupting while the loop is **blocked inside the pull itself** was not covered: the provider's stream promise never settled, so nothing came back to check. The idle timer could not rescue it either, because by then the request's internal AbortController was already aborted and the timer's own abort was a no-op. Nothing was left to end the run. + +The pull is now raced against a promise that settles the moment that controller aborts — whether the trigger was the user or the idle timer — so the request always closes out regardless of what upstream does. The abandoned stream is asked to close on a best-effort basis and deliberately not waited on: a stream that ignored its abort signal may well ignore that too. The terminal state is classified by which trigger fired, so Stop still ends the run as interrupted and an idle stall still ends it as a timeout that reconnects. + +This is provider-agnostic on purpose. Whether a request terminates when its signal fires is the harness's guarantee to keep, not something to inherit from whichever SDK happens to be underneath. diff --git a/changelog/unreleased/README.md b/changelog/unreleased/README.md index 733427e..2e75f5c 100644 --- a/changelog/unreleased/README.md +++ b/changelog/unreleased/README.md @@ -2,5 +2,16 @@ Changes since v0.1.4. The version number is assigned at release, when this folder is renamed. +- [2026-07-29] Core: every LLM failure except a rejected credential now retries inside the run — the classifier separating transient from permanent is an allowlist, so a gateway wording a transient fault its own way used to kill the turn — with the retry visible in both frontends, compaction on the same set under its own shorter budget, and a recovered failure no longer reported to the operator as an incident. Separately, pressing Stop mid-request can no longer leave a Session running forever when the provider's stream neither yields nor rejects after the abort. ([details](2026-07-29-llm-request-lifecycle.md)) + +- [2026-07-29] Cost center: the error table can page back through the whole history instead of showing only the newest 20, its source column reads `[env]` rather than `environment ·`, and an ordinary non-zero exit from a command tool is no longer recorded as an error — `grep` finding nothing had been crowding out the failures the table exists to show. ([details](2026-07-29-cost-center-errors.md)) + +- [2026-07-29] Core and tooling: `PORT` / `HOST` and the internal CLI plumbing no longer reach commands the Agent runs, so a dev server started by `exec_command` picks its own port instead of binding the harness's; and the development backend moves to 7368, so `pnpm dev` no longer collides with an installed `penguin web` — or, quieter and worse, proxies to it. ([details](2026-07-29-harness-env-and-dev-ports.md)) + +- [2026-07-28] Web App: model configuration stops inviting the browser's saved login — every form control opts out of autofill unless it declares a real credential role, and a secret field says `new-password`, the only value Chrome and Safari honor on a password box; a closed docked side panel no longer paints its 1px divider next to the open one, which read as a second, empty panel beside the Workspace files; a provider-signature message carrying no text stops drawing an empty reply bubble after a thinking segment; Benchmark costs now follow the display currency selected in settings; the tool card states a call's outcome once instead of twice; and no page can grow the document into a second scrollbar that drags the whole shell. ([details](2026-07-28-web-app.md)) + - [2026-07-28] Web App and CLI: duration and byte abbreviations now carry into the next unit instead of printing `1m60s` or `1024KB` — both helpers rounded the value they displayed but chose the unit (or the minute/second split) from the raw input. ([details](2026-07-28-duration-and-byte-formatting.md)) -- [2026-07-29] Tooling: Agent-spawned commands no longer inherit PenguinHarness-owned server variables such as `PORT` / `HOST`, while the development backend moves off the installed server's default port and the Web dev proxy follows that backend. ([details](2026-07-29-harness-env-and-dev-ports.md)) + +- [2026-07-27] Windows: the `win32-x64` package bundles MinGit under `git/`, so `exec_command` has a real bash even on a machine with no Git for Windows — the shell stops depending on what happens to be installed. A user's own Git for Windows still wins (its MSYS userland is the fuller one); the bundle is the floor, and PowerShell is now reached only by npm installs. GPLv2 obligations are recorded in a new root `THIRD-PARTY-NOTICES.md`. ([details](2026-07-27-windows-bundled-shell.md)) + +- [2026-07-27] Sites: the 0.1.4 release post in both languages, with a capture script for its screenshots. ([details](2026-07-27-sites-and-blog.md))