Skip to content

x - #100

Closed
asto18089 wants to merge 4611 commits into
4095-v0868-ux-default-tui-presentation-is-too-busy-compact-mode-should-be-standardfrom
fix/kimi-mfjs-empty-items-drop
Closed

x#100
asto18089 wants to merge 4611 commits into
4095-v0868-ux-default-tui-presentation-is-too-busy-compact-mode-should-be-standardfrom
fix/kimi-mfjs-empty-items-drop

Conversation

@asto18089

Copy link
Copy Markdown
Collaborator

x

CodeWhale Bot added 30 commits September 2, 2026 18:06
Legacy deepseek_theme token module was never user-selectable; expose its
flat phosphor-teal era as ThemeId::UnderwaterRetro (underwater-retro,
retro) with underwater ink roles on a flat unpainted ground.
Merge groups of tiny same-area #[test]/#[tokio::test] fns into single
scenario fns; every statement/assert moved verbatim into its own block
scope, zero assertions dropped (assert-line counts identical
before/after in each file). No test deleted, so no golden-file /
cucumber / cross-test justification needed. Non-test code untouched;
cfg-gated, ignored, should_panic, and early-return tests left in place.

#[test]/#[tokio::test] counts:
- crates/tui/src/config/tests.rs: 413 -> 337
- crates/tui/src/core/engine/tests.rs: 412 -> 340
- crates/tui/src/tui/app/tests.rs: 301 -> 218
- crates/tui/src/client.rs (tests module only): 206 -> 133
- crates/tui/src/runtime_api/tests.rs: 171 -> 162
- crates/tui/src/runtime_threads/tests.rs: 170 -> 159
Total: 1673 -> 1349 (-324) via 129 <area>_scenario fns.

Verify: RUST_MIN_STACK=16777216 cargo test -p codewhale-tui --lib --
config::tests:: core::engine::tests:: tui::app::tests:: client::tests::
runtime_api::tests:: runtime_threads::tests::
=> 1466 passed, 0 failed, 6 ignored (pre-existing ignores).
Note: clippy flags pre-existing too_many_arguments in tui/ocean.rs
(untouched by this slice).
qiuYliangM and others added 29 commits September 17, 2026 12:06
… labels (#59)

* fix(context): prompt instructions source as file name

The <project_instructions source="..."> label carried the absolute path
of the loaded AGENTS.md. The label sits inside the pinned system
prompt, so loading an unchanged file from a moved or recased directory
rewrote the label and emitted a spurious <context_update> history
append, and it leaked the absolute project path into a provider-bound
prompt label.

The label now reports the file name only. Directory identity is
discoverable at runtime via the shell; the label is an origin tag, not
a locator. A regression test pins byte-identity of the block for
identical content loaded from two different directories.

Signed-off-by: qiuYliangM <185303122+qiuYliangM@users.noreply.github.com>

* refactor(context): share the project_instructions source label helper

v0.9.12-line adaptation: the helper lands in the split-out
project_context/types.rs and is re-exported through project_context.
This line's compaction was fully restructured, so the verbatim
AGENTS.md reinjection (project_instructions_section) no longer exists
and the old-line forkguard_compaction_reinject_* tests have no
injection point to port; the <!-- global: {path} --> label in
merge_global_and_project_instructions is deliberately untouched
because the home path does not drift with the workspace.

- Extract the shared project_instructions_source_label helper (file
  name, fallback "project"); as_system_block and the context report's
  base_source_entries now share it instead of keeping one file-name
  logic copy each.
- Rename the source-relativization regression test with the
  forkguard_ prefix.

Signed-off-by: qiuYliangM <185303122+qiuYliangM@users.noreply.github.com>

* fix(context): apply the file-name source label to the constitution block

Review round 1 on the split PR: the constitution block sits in the same
pinned system-prompt region as <project_instructions> and still carried
an absolute source path, while its loader always resolves the fixed
relative path (".codewhale/constitution.json"), so the shared helper
applies with no new decision. The locator stays available via
constitution_source_path in the report and /constitution.

Also aligns the story with what this line actually does: the pinned
system prompt is never rewritten (workspace drift reaches the model as
a bounded <context_update> history append), so the honest benefits of
the file-name label are (1) no spurious context_update append after a
directory move or recase, (2) absolute project paths stay out of
provider-bound prompt labels, and (3) one shared helper for both label
renderings. Ancestor-chain and project-rule labels keep their absolute
spellings for now; relativizing those needs a repo-relative decision
and is tracked in the parent repo (Pinvou/pinvou-agent#514).

Also includes: a unit test for the helper's "project" fallback, the
test comment and inline comment translations to match the base
language, and an assert message scoped to what the regression test
actually pins.

Signed-off-by: qiuYliangM <185303122+qiuYliangM@users.noreply.github.com>

* docs(context): correct the constitution source comment

The rendered source is a runtime-canonicalized absolute path; only its
final segment (REPO_CONSTITUTION_RELATIVE_PATH) is a compile-time
constant. The basename-stability claim holds, but the earlier wording
implied the whole path was constant.

Signed-off-by: asto18089 <asto18089@126.com>

---------

Signed-off-by: qiuYliangM <185303122+qiuYliangM@users.noreply.github.com>
Signed-off-by: asto18089 <asto18089@126.com>
Co-authored-by: qiuYliangM <185303122+qiuYliangM@users.noreply.github.com>
Co-authored-by: asto18089 <asto18089@126.com>
…est clippy (#60)

* chore(baseline): resync the tui changelog slice, surface facts, and test clippy

Split out of CodeWhale#56 per CONTRIBUTING.md: changelog entries are
written on main at merge time and PRs carrying changelog hunks are asked
to strip them, so the DDG-to-Bing degradation entry (behavior already
merged in #44 via ff299f9; the root CHANGELOG already carried it, the
packaged tui slice did not) moves here together with its generated
web twin. The surface-facts trio moves with it because no gate on the
pinvou3-clean lane checks them; note v0.9.13 published on 2026-09-14,
so the next receipts run should refresh latestPublishedRelease again.
The redundant-closure cleanup in engine/tests.rs rides here because it
blocks the master/main and release lint lanes, not the fork lane.

Signed-off-by: asto18089 <asto18089@126.com>

* fix(web): evolve telemetry trust prose with the release bump

The surface-facts contract interpolated latestPublishedRelease.version
into a required 'published <version> release asked first' phrase. Once
the pin moves to v0.9.12 that demanded phrase becomes false: 0.9.12
counts by default and never asked. Name the historically fixed asking
release instead, matching the contract's upstream evolution: prose says
'earlier 0.9.11 release asked first' (en + zh), the test asserts it as
a literal, and the roadmap/faq/telemetryLead copies follow. Catalogs
regenerated with npm run i18n:gt -- export; npm test 407/407 green.

Signed-off-by: asto <asto18089@126.com>

* test(web): anchor the asking-release phrase across site faces

Review follow-up: the telemetry trust prose this PR evolves is mirrored
across the en/zh dictionaries and the faq/roadmap pages with no test
anchor, so a future edit could update one face and silently leave the
others behind. Assert the fixed 'earlier 0.9.11 release asked first'
phrasing (and its zh mirror) in every face and reject the interpolated
'published 0.9.11' wording; make providerCountDefinition version-free
('the providers of any published release') so the same stale-pin drift
cannot recur there; refresh the stale comment above the telemetry
assertions.

Signed-off-by: asto18089 <asto18089@126.com>

---------

Signed-off-by: asto18089 <asto18089@126.com>
Signed-off-by: asto <asto18089@126.com>
#61)

* fix(fork): route the stopship scout through tool_search and grep_files

The read-only scout brief commanded a `File` call with `action`
`search_content`, but `File` is a hidden replay-only alias
(`model_visible=false`): it never reaches a model-visible catalog, and
the child step loop fails any call outside the child's policy-filtered
catalog outright. The release-acceptance explore gate's first step
therefore could not succeed; dispatch-by-alias only worked below the
catalog gate (replay tests calling `registry.resolve` directly).

Route the scout through the live surface instead: response 1 activates
the deferred `grep_files` with one `tool_search` call, response 2
runs the same alternation search through `grep_files`
(path/include/pattern/max_results/context_lines), and response 3
returns the verdict. The step and token caps are unchanged; the
evidence turn grows one small activation response.

Guard both halves so neither side silently drifts again: one forkguard
test pins the scout surface to an active `tool_search` plus a
deferred, searchable `grep_files` with no `File`, and another pins
the fixture definition to catalog-visible tool names only.

Signed-off-by: asto <asto18089@126.com>

* test(fork): align stopship guards with scout repair

The review of #61 caught one real break and three guard-fidelity gaps:

- js_authoring.rs still pinned the retired `File`/`search_content`
  scout wording, so the workspace suite and CI went red on this
  branch. Pin the new three-response contract instead.
- The scout surface guard built its registry with no explicit scope,
  while the fixture scout really runs under the read-only lowering
  `["File"]`. Build it with that scope so the alias-family
  intersection that keeps grep_files discoverable is guarded too.
- The brief guard only denied backticked `File` and bare
  `search_content`; deny the hidden replay aliases as whole words so
  an unbackticked citation cannot slip past.
- Add the behavioral counterpart: one tool_search call must make
  grep_files dispatchable through execute_from_surface, and a
  File call must keep failing the catalog gate.

Signed-off-by: asto <asto18089@126.com>

---------

Signed-off-by: asto <asto18089@126.com>
Signed-off-by: asto18089 <asto18089@126.com>
* fix: preserve tool rounds after compaction

Signed-off-by: z27014 <zhuo.wenpei@h3c.com>

* fix: normalize compacted tool rounds on chat wire

Signed-off-by: z27014 <zhuo.wenpei@h3c.com>

* fix: preserve real user boundary during recompaction

Signed-off-by: z27014 <zhuo.wenpei@h3c.com>

* fix: preserve compacted turns across restores

Signed-off-by: z27014 <zhuo.wenpei@h3c.com>

* fix: identify generated compaction summaries

Signed-off-by: z27014 <zhuo.wenpei@h3c.com>

* fix: scope restored topology wire merging

Signed-off-by: z27014 <zhuo.wenpei@h3c.com>

* fix(tui): repair the compaction checkpoint tiers and pre-fix restore order

The checkpoint is one engine-owned history message, but the tiers that find,
delete, and replace it disagreed with the tier that reorders it. Restore and
recompaction decided by marker substring, so an ordinary user turn that quoted
the header was deleted — silently, because the coverage floor used the same
substring and exempted the message it lost — and `/edit` still treated the
generated summary as an editable turn. Recognize the carrier by structure
instead: a user message whose first text block begins with the summary header
and whose remaining block, if any, is the engine-written provenance marker.
Sessions saved before that marker existed carry the bare summary, so the
single-block header-prefix form stays recognized; only the provenance-stamped
form may be relocated or merged on the wire, which is what keeps a genuine
turn that pastes a header in place.

Sessions saved before the placement fix also stay broken across restore: their
topology sidecar was appended to the end of the replacement history, request
rewriting moves only the summary, and the first request after restore is
`tool -> user` again. Relocate the restored carrier to the placement anchor —
after the prompt its summary merges into, before that round's assistant/tool
chain — and teach the recompaction replay to drop a superseded restored
carrier instead of stacking a second one. One anchor definition now serves
save-time placement, restore repair, and request-time relocation, replacing
three copies that had already drifted on which messages count as a prompt.

The summary relocation also no longer requires the header text to be the
current one: a pre-0.9.6 carrier that begins at the legacy marker carries the
provenance block too, so it is relocated like any other generated checkpoint.

Signed-off-by: z27014 <zhuo.wenpei@h3c.com>

---------

Signed-off-by: z27014 <zhuo.wenpei@h3c.com>
…t state host-side (#57)

* fix(computer-use): honor the zoom raster contract and keep ssh element state host-side

Two model-facing defects in the codewhale-cu MCP server, found by the
2026-09-13 Pinvou prompt/tool/skill audit:

- zoom told the model to aim from the returned child raster
  (tools.mjs: coordinate targets are 'the latest returned raster
  (screenshot or zoom)'), but the server never rebound the raster after
  a zoom, so child pixels were resolved against the stale full shot and
  clicks landed in the wrong place. After a local zoom, rebind the
  raster frame offset by the crop region at the parent scale (all three
  desktop backends crop 1:1 via sips/GDI/ffmpeg).

- over ssh the agent is a fresh process per call, so get_app_state
  never issued a state_id and element actions dead-looped on 'call
  get_app_state again'; zoom and recording_* failed with misleading
  backend errors (or orphaned a remote ffmpeg). Element state is now
  remembered host-side (prepareWireArgs already resolves targets
  host-side), element states are checked against the acting computer
  (state_computer_mismatch), and zoom/recording_start/stop/status fail
  closed over ssh with named, actionable reasons.

Tests: new raster.test.mjs pins the child-raster geometry; new
server-protocol tests cover the ssh state_id round trip, the
cross-computer mismatch, and the ssh fail-closed reasons (POSIX-only,
like the existing ssh shim tests).

Signed-off-by: asto18089 <asto18089@126.com>

* fix(computer-use): make the zoom rebind bind, and work it over ssh

Review of the first cut found the rebind computed the child frame but
bindRaster reads `points`, so the zoom fix never took effect - and the
tests only covered the pure geometry function, so CI stayed green on a
no-op. The rebind now stores the computed origin (bindRaster accepts
`origin` for zoom children) and the child file becomes the bound
raster; all three desktop backends also advance their own last raster
to the child, so chained zooms crop from the child the way the
"last-raster pixels" contract promises. zoomChildRaster is unchanged.

ssh zoom is implemented instead of refused: the host remembers the
remote raster path from the screenshot receipt, injects it as the
crop source, and rebinds the child geometry - mirroring the screenshot
flow that already ships "pixels stay remote + scp". Recording stays
fail-closed over ssh (a recorder cannot survive the one-shot agent),
with one accurate reason for all three tools.

While wiring the zoom e2e, prepareWireArgs turned out to forward
coordinate targets as raw raster pixels over the wire, so the remote
clicked at pixel values as if they were screen points. Coordinate and
element targets are now resolved host-side for ssh exactly as
prepareLocalArgs does locally; computer_remove also evicts remembered
element states so a re-registered id cannot reuse stale observations.

Tests: the ssh shim gains a canned remote endpoint (env-gated) that
logs every wire payload, so the element-state loop, the zoom rebind
geometry (child pixel (60,45) must click at screen (90,68)), and the
eviction are pinned deterministically on headless CI instead of
vacuously. Tool descriptions, both SKILL.md files, the plugin README,
and plugin.json now match the actual ssh behavior.

Signed-off-by: asto18089 <asto18089@126.com>

* fix(computer-use): forget per-computer state on re-register and align ssh guards with upstream

Round-2 review of this PR found the protection it advertises only
half-true: computer_remove evicted a computer's remembered element
states and raster binding, but computer_register updates an existing
id in place and evicted nothing - so re-pointing an id at another host
kept the old host's observations (still matching the id, so they
dispatched onto the new host) and its raster geometry (silent wrong
clicks). Registration now detects an existing entry and drops the id's
backend cache, raster binding, and element states; the eviction lives
in one forgetComputer helper shared with computer_remove.

The ssh fail-closed family also gains the sibling fix upstream ships:
left_mouse_down over ssh fails closed (persistent_session_required) -
a press outlives the one-shot agent process, and if the follow-up
release never arrives the remote button stays stuck with nothing left
to detect or release it. Error codes align with upstream's
computer-use plugin (state_wrong_computer instead of
state_computer_mismatch; persistent_session_required instead of
unsupported_over_ssh) to avoid re-port churn.

Smaller review items: rebindAfterZoom says so when it runs with no
bound raster instead of returning silently, and zoomChildRaster
returns the guarded scale instead of raw prev.scale (the raster test
had pinned scale: undefined as expected - flipped).

Docs now say exactly what works over ssh: recording_list still lists
remote files (the README wrongly grouped it into the refusal), and
ssh zoom delivers correct aiming while the crop file can only be
viewed out-of-band (the plugin has no pull tool). New tests: in-place
re-register eviction, ssh press-and-hold refusal, drag endpoints
resolving against the rebound child raster, and the foreign-source
zoom note path. Suite: 44/44.

Signed-off-by: asto18089 <asto18089@126.com>

* fix(computer-use): make win32 left_mouse_down actually press the button

A review pass found the win32 press-and-hold implementation silently
reporting success without pressing: the script argument read

  target ? "SetCursorPos...;" : "" + "mouse_event LEFTDOWN..."

and since + binds tighter than ?:, a target-bearing call (the model's
standard usage) parsed as target ? (cursor move only) : (move + press),
so the cursor moved, nothing was pressed, and the receipt still said
action_sent: true. Parenthesize the conditional so the press is part of
both arms; only the no-target arm skips the cursor move. Pre-existing
bug on the base, fixed here because this PR rewrites the tool's
per-transport guidance and left_mouse_down is its subject.

No test: the script body is only assembled on win32, where this suite
cannot run; the change is a pure JavaScript precedence fix.

Suite: 47 tests (46 pass, 1 platform-skip).

Signed-off-by: asto18089 <asto18089@126.com>
Signed-off-by: asto <asto18089@126.com>

* fix(computer-use): route press-and-hold guidance to computers that support it

The round-3 disclosure pass claimed more than the backends deliver:
left_mouse_down was advertised as "Local and hdc computers only" and the
ssh refusal told the model to "use a local or hdc computer", but the
HarmonyOS backend throws on press-and-hold (uitest uiInput exposes no
press/release) - the guidance would route the model into a guaranteed
backend error, the exact model-facing falsehood this PR set out to
remove. Describe press-and-hold as local-only and name left_click_drag
as the hdc alternative in the tool description, the ssh refusal, and
the README.

Two smaller description mismatches while in the file: recording_status
said "unknown over ssh" where it actually fails closed with
persistent_session_required (matching the start/stop wording, so the
model does not retry hoping for a status), and switch_display now
discloses that the choice dies with the one-shot ssh agent process -
pass display to screenshot/recording_start instead.

Signed-off-by: asto18089 <asto18089@126.com>
Signed-off-by: asto <asto18089@126.com>

* test(computer-use): pin zoom crop pixels, chained advance, and remove eviction

Review found the ssh shim tests pin only the host side of the zoom
contract: the remote 1:1 crop (sips --cropOffset, ffmpeg crop, GDI
Rectangle) and the backends' last-raster advance had no automated
coverage, so a swapped crop argument would keep the suite green while
real clicks landed wrong. Add a minimal PNG codec (8-bit RGB/RGBA,
non-interlaced, PNG filters 0-4 on decode) and pixel-level zoom tests
against the real croppers: sips on macOS, ffmpeg on linux (skipped
elsewhere). A deterministic gradient makes every sampled pixel identify
its source coordinate, so a swapped cropOffset/crop order, a wrong
region, or a dropped last-raster advance all fail the assertions; the
source-less chained zoom also pins that the child becomes the last
raster. Tests set CODEWHALE_CU_RECORDINGS_DIR so the recordings mkdir
never touches the real home.

The computer_remove eviction test could not see the difference between
remove forgetting states and the re-register eviction covering for it:
probing through a second registered computer id surfaces the
distinction (state_wrong_computer would mean the state survived the
remove). Verified both directions: dropping the darwin advance or the
remove's eviction turns the new tests red.

Suite: 47 tests, 46 pass + 1 platform-skip.

Signed-off-by: asto18089 <asto18089@126.com>
Signed-off-by: asto <asto18089@126.com>

* fix(computer-use): close switch_display and repoint races over ssh

Review of this PR found two model-facing holes in the ssh contract it
establishes:

- switch_display was still dispatched to the one-shot remote agent,
  where it sets backend state in a throwaway process and returns ok:true
  while the choice silently evaporates — the exact silent-success class
  the recording fail-closed family fixes. It now fails closed with
  persistent_session_required before any ssh spawn, naming the way out
  (pass display to screenshot/recording_start), and the tool description
  says it refuses instead of implying the setting quietly dies. The
  screenshot description also gains the zoom carve-out: coordinate
  targets refer to the latest returned raster, not whichever raster the
  sentence happens to sit on.

- get_app_state remembered the observed tree, and zoom rebound the
  raster, AFTER the wire await; a racing computer_remove +
  computer_register could repoint the id at another host in between and
  the old host's observation or crop path would be stored under an id
  that now names the new host (state_wrong_computer cannot catch it, and
  the wrong click lands silently). assertComputerUnchanged re-reads the
  registry after the await and fails closed with computer_repointed
  before any host-side memory write — applied to the screenshot/zoom/
  get_app_state writes on both the ssh and local/hdc branches, which
  race the same way through await getBackend.

Verified: switch_display against an unreachable ssh host returns the
error in ~120 ms with no ssh spawn; npm test unchanged apart from the
test-commit fixes (32 pass, 3 pre-existing environmental fail, 12 skip
on win32).

Signed-off-by: asto18089 <44870036+asto18089@users.noreply.github.com>

* test(computer-use): make ssh shim tests win32-safe and leak-free

- The pre-existing registering-ssh test had no win32 skip: the
  extensionless POSIX shims cannot execute on Windows, so node fell
  through to the real ssh.exe and the test attempted a genuine network
  connection to box.test. It now skips on win32 like the newer shim
  tests (whose skip messages point at it).
- The ssh drag-endpoints test silently relied on the previous test's
  zoom binding, so it failed under --test-name-pattern; it now performs
  its own screenshot+zoom setup and binds the same child frame itself.
- The after() hook never removed binDir, leaking cu-proto-bin-* on every
  run; the two zoom pixel tests leaked cu-zoom-* dirs on CI (macOS/
  linux) — both now clean up (t.after, so cleanup survives assertion
  failures).

Suite on win32: 32 pass, 3 pre-existing environmental fail (CRLF
manifest regex, two URL.pathname agent-spawn tests), 12 skip — the
registering-ssh test moved from fail to skip as intended.

Signed-off-by: asto18089 <44870036+asto18089@users.noreply.github.com>

* docs(computer-use): fix press-and-hold grammar in README

"cannot keep a recorder alive or guarantee a press its release" was
missing a word; a press must outlive its release.

Signed-off-by: asto18089 <44870036+asto18089@users.noreply.github.com>

* fix(computer-use): compare the whole connection when guarding in-flight results

The repoint guard compared only transport and host, but a re-register
merges in place and can repoint an id by changing just the port
(host:22 and host:2222 can be different machines behind NAT), the
user, or the hdc target - hdc entries have no host, so both sides
compared undefined === undefined and any device swap passed. The guard
now compares a connection key built from
transport/host/port/user/target; reproduced end-to-end before the fix:
an observe in flight on box.test:22 plus a re-register to box.test:2222
returned a state_id that then dispatched the old host's element path
onto the new registration.

computer_register now also evicts remembered state only when the
connection actually changed. The unconditional eviction silently reset
a local computer's display choice and orphaned a running recorder on a
byte-identical re-register; a no-op is not a repoint.

Signed-off-by: asto18089 <44870036+asto18089@users.noreply.github.com>

* fix(computer-use): validate zoom region on linux and win32 like darwin

darwin.zoom rejects a malformed region with a named ExecError before
touching anything; linux and win32 dereferenced region directly, so a
missing, short, non-numeric, or negative region died as a TypeError
inside the ffmpeg argv or the PowerShell script instead of the named
error the tool contract promises. Both now apply darwin's guard
verbatim, before the ffmpeg tool probe and the recordings dir setup so
the check is cheap and cross-platform testable.

Signed-off-by: asto18089 <44870036+asto18089@users.noreply.github.com>

* fix(computer-use): give ssh holds wire time and scope the switch_display way out

- hold_key runs down, sleep, and up inside the one remote call, but the
  ssh wire timeout was a flat 30s while hold_key allows 30s durations -
  a full-length hold was killed at the wire right as it finished,
  leaving the remote key down with the agent process gone (the exact
  stuck-state class the left_mouse_down refusal exists for). The
  timeout now adds the hold duration on top of the 30s base.
- The switch_display refusal's way out, pass display to
  screenshot/recording_start, is only real on macOS: linux and windows
  screenshots grab the whole desktop/virtual screen and ignore display.
  The refusal and the tool description now say so instead of promising
  a knob that does nothing on two of the three remote platforms.

Signed-off-by: asto18089 <44870036+asto18089@users.noreply.github.com>

* test(computer-use): pin the repoint guard, eviction scope, and ssh refusals

The round-3 guards shipped with two of their number untested. Now
pinned, each reverse-verified (reverting the fixed behavior turns the
test red):

- computer_repointed: a get_app_state kept in flight by a canned
  sentinel (app_ref "slow-app" holds the fake remote for 700ms) with a
  computer_remove landing mid-call, and the port-only repoint the
  previous guard comparison could not see.
- switch_display over ssh fails closed with persistent_session_required,
  like its two sibling guards already asserted.
- a byte-identical re-register keeps runtime state: eviction is for
  repoints, not no-ops.
- linux and win32 zoom refuse malformed regions with the named error
  (runs on every platform: the check fires before any tool probe).

Also removes the pre-existing cu-agent-deny-* temp dir leak so the
suite's mkdtemp footprint is fully cleaned (the leak-free claim covered
the new dirs; this makes it true for the suite).

Suite: 53 tests, 52 pass + 1 platform skip.
Signed-off-by: asto18089 <44870036+asto18089@users.noreply.github.com>

* fix(computer-use): clip zoom regions to the source raster and bind the clip

An out-of-bounds zoom region exposed a binding defect: the croppers
adjust such a request on their own — ffmpeg clamps the origin silently
(a region [50,0,32,32] on a 64-wide raster crops from x=32, exit 0,
no warning even at default verbosity; oversized w/h die as an opaque
error) — while every backend echoed the requested region, so the
server bound child pixels against a crop that was never taken and the
zoomed-in click landed 18 raster px beside the target. Linux and win32
also accepted a zero-area region past validation into an opaque cropper
error.

Every backend now clips the region against the actual source raster
size before cropping and returns the clipped rect, so the receipt's
region is the crop actually taken; the host binding needed no change —
it already binds the returned region. The clip lives in one shared
clampRegion (src/raster.mjs): round, reject w/h below 1 with the named
region error, intersect with the raster bounds. linux probes the size
with ffprobe (ships with the ffmpeg zoom already requires), darwin
with sips -g pixelWidth/pixelHeight, and the win32 script clamps the
rect it draws itself and reports the pixel size for node to recompute
the same clip. Zero-area regions are rejected in the pre-probe
validation of all three backends.

Also fix ssh zoom on a real linux remote, found while wiring this in:
the one-shot agent process never runs probeSession, so the need()
cache was empty and every remote zoom failed with "needs ffmpeg" even
with ffmpeg on PATH. zoom now primes the probe itself before checking
the tools.

Tool description, README, and the recording skill disclose the clip.

Signed-off-by: asto18089 <44870036+asto18089@users.noreply.github.com>

* test(computer-use): pin the zoom clip, the chained clip, and the ssh aiming

clampRegion unit tests pin the intersection, the rounding, and the
degenerate rejects. Pixel-level regressions against the real croppers
(sips on macOS, ffmpeg on linux/CI) pin an out-of-bounds region end to
end: the receipt carries the clipped region and the child pixels come
from the clipped origin — [50,30,32,32] on a 64x48 gradient crops from
(32,16) — and a chained zoom clips against the child raster. The
malformed-region loop gains a zero-width region, which must die as the
named error before any probe. The ssh shim gains a canned clip mirror
so the host-side half is pinned on headless CI: after a clipped zoom,
a child-pixel click must cross the wire at the clipped screen point
(26,20), not the one the raw request would have bound (35,20). Each
pin is reverse-verified: reverting the fixed behavior turns it red.

Suite: 58 tests, 56 pass + 2 macOS-only skips on linux.

Signed-off-by: asto18089 <44870036+asto18089@users.noreply.github.com>

---------

Signed-off-by: asto18089 <asto18089@126.com>
Signed-off-by: asto <asto18089@126.com>
Signed-off-by: asto18089 <44870036+asto18089@users.noreply.github.com>
…on tokio Semaphore (#55)

* refactor(subagent): correct governor comments, drop dead launch-note branch

PR#43 review follow-up outside governor.rs:

- mod.rs: the attempt-report comment claimed the AIMD ratio denominator
  counts retried attempts, but the client inner with_retry (default
  max_retries = 3) aggregates up to 4 real HTTP attempts into one
  visible result, so one loop iteration is at most one governor event.
  The no-governor probe-period comment claimed the wall-time deadline
  always fires first, but the probe tick is simply a no-op without a
  governor. The rate_limit_governor() doc named the engine as the
  stamper; the actual stamp site is session bootstrap in lib.rs.
- mod.rs/engine.rs: update_runtime_limits always returned true since
  the DynamicGate applies capacity live, so the engine branch that
  appended 'launch_concurrency takes full effect after active
  sub-agents finish...' was unreachable. Drop the dead branch and the
  now-meaningless bool return.
- mod.rs/tests.rs: extract is_governor_reported_rate_limit (the
  predicate deciding what feeds the governor window) and anchor the
  QuotaExhausted exclusion with a forkguard_ test: quota exhaustion
  must produce no governor event and keep the fatal non-retryable
  path, while a genuine 429 does feed the governor.

Signed-off-by: asto18089 <asto18089@126.com>

* refactor(subagent): rebuild DynamicGate on tokio Semaphore forget_permits

Review follow-up to PR#43's adaptive rate-limit governor:

- DynamicGate replaced its hand-rolled FIFO waiter queue (Mutex +
  VecDeque + oneshot pre-counted permit handover + disarm-on-drop)
  with a thin accounting layer over tokio::sync::Semaphore. Tokio
  1.53's forget_permits makes shrink-below-active possible on the
  stock primitive: shrink forgets free slots, holders above the new
  capacity finish naturally, and their releases are absorbed instead
  of re-admitted. The semaphore owns the wait queue, FIFO fairness,
  and cancellation safety (a cancelled acquirer dequeues itself, so
  the stale-entry/disarm machinery is deleted outright). Invariant:
  sem.available_permits() == capacity.saturating_sub(outstanding).
  Behavior contract is unchanged; the cancel/redispatch and
  abort/pause stress tests pass unmodified.
- The ratio heuristic contradicted its own comment: with
  THROTTLE_EVENT_THRESHOLD == 2, requiring the ratio branch to also
  see >= 2 events would make it dead code, and as written one success
  plus one 429 halved the gate on the first blip. Drop the ratio
  branch and its constant; the absolute event threshold owns all
  shrinking. The test that pinned the old behavior is rewritten as
  forkguard_single_rate_limit_blip_does_not_shrink_gate.
- Rename the remaining eight fork-behavior tests with the forkguard_
  prefix so the parent repo's fork-guard fingerprints can lock them.
- Correct module docs: successes are counted fleet-wide per
  successful LLM call (not per gate-holder), and the Semaphore
  can't-shrink justification is replaced with the forget_permits
  design note.

Signed-off-by: asto18089 <asto18089@126.com>

* chore(tui): drop needless returns breaking clippy -D warnings

Pre-existing on the v0.9.12 baseline (unrelated to the governor
follow-up): clippy 1.97 flags needless_return at mcp.rs:288 and
lib.rs:12547, failing 'cargo clippy -p codewhale-tui --lib --locked'
for every PR. Remove the returns; on Windows the cfg(windows) block
becomes the function tail, on non-Windows the cfg(not(windows)) block
already was, and the not(unix) early-return block is redundant with
the cfg(unix) gate on the spawn statement.

Signed-off-by: asto18089 <asto18089@126.com>

* docs(subagent): fix invariant and tokio version notes

Review follow-ups to the governor follow-up itself, all comment-level:

- governor.rs: `Semaphore::forget_permits` is stable since tokio 1.37,
  not 1.53 as the module doc claimed. The available/capacity/outstanding
  invariant holds at rest; an admission between taking its semaphore
  permit and registering `outstanding` (and a capacity change racing
  that window) can bend it transiently by the number of in-flight
  admissions, and the drop accounting restores it as holders release.
- mod.rs: the pinned acquire future now matters because re-creating it
  per probe tick would dequeue and re-enqueue at the back of the FIFO
  wait queue; the old stale-entry-purge rationale died with the
  hand-rolled queue.

Signed-off-by: asto18089 <asto18089@126.com>

* docs(subagent): correct DynamicGate invariant drift wording

The struct doc claimed the drop accounting restores the invariant once
holders release. Not guaranteed: a capacity shrink racing an in-flight
admission (permit taken, registration pending) cannot see the slot, and
a subsequent expand over-adds one semaphore permit per racing admission.
The surplus is bounded and never loses a slot, but it drains only when
demand pushes the fleet past capacity (excess releases are absorbed) or
a later shrink clamps at the available count — until then it persists as
effective extra capacity across further pause/resume cycles. State that
precisely, align the remaining 'active' prose with the outstanding field
name, and describe available_permits as what it actually reads.

Signed-off-by: asto <asto18089@126.com>

* test(subagent): pin gate semaphore permits across capacity changes

The capacity-focused gate tests assert the bookkeeping capacity() field
alone; the stress test's drain assertion was the only direct pin on the
semaphore-side expand/shrink arithmetic and the Drop absorb guard. Add a
deterministic test that holds permits outstanding across an expand and a
shrink-below-outstanding and asserts available_permits() at each step,
so breaking that arithmetic fails on its own instead of depending on the
stress test staying strict.

Signed-off-by: asto <asto18089@126.com>

* fix(subagent): refuse surplus gate tokens that bypass pause

A grant handed to a queued waiter that is cancelled before polling
again is re-injected into the semaphore pool by tokio's cancel path
(batch_semaphore Drop for Acquire re-releases partial grants). A
shrink cannot see it (forget_permits covers free tokens only), so a
429 pause (capacity 0) could find a spare token in the pool and
admit via try_acquire/acquire while throttled. The grant-races-
shrink window had the same flaw from the opposite side.

The slot ledger becomes the admission authority on both paths via a
shared admit_grant: a token granted while outstanding >= capacity is
surplus drift and is destroyed with forget (never dropped; a drop
would re-inject it), and acquire discards it and keeps waiting.
Releases still mint one token per slot freed below capacity, so no
slot is ever lost, and acquire's discard loop consumes one pool
token per round, so it cannot spin. The gate can no longer
over-admit, replacing the previously documented surplus window.

Adds forkguard_cancelled_grant_returned_after_shrink_cannot_bypass_
pause (deterministic current-thread interleaving: release, shrink
to pause, abort the granted waiter, both admission paths refuse)
and rewrites the DynamicGate invariant docs to match.

Verified: fmt clean; clippy -p codewhale-tui --lib --locked zero
warnings; subagent::governor 16 passed; forkguard_ 59 passed;
subagent:: 567 passed (a stack overflow in coord::tests::
coordination_resolution_survives_reload_and_resolving_claim_eviction
reproduces on the unmodified PR head on aarch64 Linux and is
unrelated).

Signed-off-by: asto <asto18089@126.com>

---------

Signed-off-by: asto18089 <asto18089@126.com>
Signed-off-by: asto <asto18089@126.com>
fix(tui): bound the Windows PowerShell fallback

Review round 3 follow-ups to the execution-policy work; the topic's behavior
tests also move under the forkguard_ prefix so the parent fork-guard floor
covers them instead of relying on textual fingerprints alone.

- Retry only our own temporary script: the refusal must name the exact -File
  path this invocation passed. A nested `powershell -File inner.ps1` child is
  refused at its own top level (no <path>.ps1:<line> location line) after the
  outer script already ran its earlier statements, so the exact-path rule is
  what keeps that shape from repeating the outer script's side effects.
- Bound the encoded form by the Windows command-line limit (32767): UTF-16LE
  base64 expands a payload to ~2.67 characters per input character, so a
  payload that cannot be spawned inline keeps its first result instead of
  trading a policy refusal for a spawn failure.
- Keep the first attempt's refusal, annotated with the retry error, when the
  inline retry cannot even start, instead of propagating the recovery path's
  error and discarding the refusal evidence.
- Merge the maintenance branch (pinvou3-clean) into the candidate so the
  candidate tree equals the squash tree, which also picks up its needless_return
  clippy fix.

Verified on Windows: cargo test -p codewhale-tui --lib powershell (14 passed)
and windows_shell_text (1 passed); cargo fmt clean; cargo clippy -p
codewhale-tui --lib --all-targets --locked -- -D warnings clean.

Signed-off-by: z27014 <zhuo.wenpei@h3c.com>

fix(tui): key the UTF-8 prefix on the shell family

Round-2 review follow-up on #66: the constructor dedup moved the encoded
fallback onto `windows_shell_text`, which prefixed only the two detected
PowerShell variants, so a `ShellKind::Custom` PowerShell path (accepted by
`is_powershell`) would have run the retry without the UTF-8 output prefix and
decoded non-ASCII stdout with the console code page.

Key the prefix on the PowerShell family instead, which also aligns the normal
shell spec: a custom PowerShell path now gets the prefix it always needed.
Pinned by `windows_shell_text_matches_the_shell_family` across `Pwsh`,
`WindowsPowerShell`, `Cmd`, `Custom{pwsh}`, and `Custom{bash}`.

Signed-off-by: z27014 <zhuo.wenpei@h3c.com>

fix(tui): tighten the PowerShell retry gate

Round-1 review follow-up on #66.

- Only a `Failed` attempt may be retried: a `Killed` (the user cancelled) or
  `TimedOut` result previously reached the refusal predicate, and re-running
  those would defeat the stop or double the wall clock. The gate is now the
  testable `powershell_refusal_needs_inline_retry` helper and is still
  evaluated at the call site.
- Cover the glue: a gate test across every `ShellStatus`, plus a test that
  drives `retry_powershell_without_script_file` and asserts the inline rerun
  ran the payload, annotated the fallback, and accounted for both attempts.
- Collapse the duplicated Windows shell-text/env/struct construction shared by
  `CommandSpec::shell` and `CommandSpec::powershell_encoded_shell` into one
  private constructor so the two cannot drift.

Signed-off-by: z27014 <zhuo.wenpei@h3c.com>

fix(tui): run PowerShell under a policy bypass

A Windows machine whose execution policy is Restricted (the client default),
AllSigned, or RemoteSigned with a Mark-of-the-Web script refuses the temp
`.ps1` this dispatcher writes for multiline, quote-heavy, or non-ASCII
commands - in a Chinese session that is most commands - with "cannot be
loaded because running scripts is disabled". PowerShell rejects the file
before a single statement runs, so the tool loses the command outright.

Pass `-ExecutionPolicy Bypass`, which sets only the process scope: it needs
no administrator rights, leaves the user's own shells untouched, and the
execution policy is not a security boundary. A Group Policy still overrides
that parameter, so when the `-File` form is refused the tool re-runs the
identical payload once inline as `-EncodedCommand` (UTF-16LE base64: not a
script file, no quoting or BOM surface), which survives Group Policy and
AppLocker script rules. The retry keeps the sandboxed exec path and the same
policy/env, and it is side-effect free because a refused script ran nothing.
Whether to retry is decided at the call site, not inside the retry helper.

Refusal detection reads error-record metadata instead of the localized
message: `UnauthorizedAccess` plus the refusal's exception type
(`ParentContainsErrorRecordException` on the host `-File` path,
`PSSecurityException` in-session), with the English `about_Execution_Policies`
topic as fallback. A `<path>.ps1:<line>` statement location rules every
branch out, because it proves a statement already ran - the case where an
inner script was refused after the outer one did work - and re-running there
would duplicate side effects. The `LinkID=135170` URL is deliberately not
matched: the console formatter wraps it mid-token.

Tests: launch flags carry the bypass for both forms, the encoded payload
round-trips to the wrapped command, the predicate matches the captured
en-US/zh-CN/ja-JP refusals and rejects both in-script failures and inner
refusals, and both forms run a non-ASCII payload end to end on Windows.

Signed-off-by: z27014 <zhuo.wenpei@h3c.com>
* ci(sync): skip the CNB mirror when its token secret is absent

`sync` fails on every push to a mirrored branch with "CNB_GIT_TOKEN secret is
not set; cannot push to CNB". That is a repository-secret state, not a property
of the commit: the check has been red for ten consecutive runs across six
unrelated branches, and reviewers of unrelated PRs correctly call it
pre-existing. Forks cannot inherit the secret at all, so they can never make
the check green.

Probe the secret in a gate job and skip the push when it is absent, with a
notice naming the missing secret. Configured repositories keep the previous
behavior, including the existing hard failure inside the push step.

Signed-off-by: z27014 <zhuo.wenpei@h3c.com>

* ci(sync): document the missing-secret skip path

Review follow-up on #64 (CHANGES_REQUESTED):

- docs/CNB_MIRROR.md: document the new behavior. When `CNB_GIT_TOKEN` is
  absent the `credentials` gate reports `configured=false`, the `sync` job is
  skipped instead of failed, and the run emits a notice naming the missing
  secret; the rotating section now distinguishes "skipped because absent or
  misspelled" from "failed because expired".
- Reflow the kept-guard comment: a `workflow_dispatch` always uses the
  dispatched ref's revision of this file, so the guard's real coverage is a
  push carrying an older revision of the workflow plus the window where the
  secret is deleted between the gate probe and the push step.

Verified: the workflow parses (`yaml.safe_load`, jobs credentials+sync,
`sync.if = needs.credentials.outputs.configured == 'true'`), and the skip path
is exercised on this branch by a `workflow_dispatch` run linked in the PR.

Signed-off-by: z27014 <zhuo.wenpei@h3c.com>

---------

Signed-off-by: z27014 <zhuo.wenpei@h3c.com>
* fix(agent): pass agent receipts through bounded

Non-snapshot agent receipts (roster catalogs, wait/settled status,
peek summaries) were destroyed by the context summarizer into
placeholder lines. Bound and pass them through verbatim instead.

Signed-off-by: asto <asto18089@126.com>

* fix(engine): make handle_read hint honest

The parent-context hint and the slash dispatch brief promised that a
deferred tool "hydrates when called by name", but tool_search hydration
only reaches allowlisted catalog entries and returns a schema, not an
execution; the compactor now mentions transcript_handle only when the
receipt actually carries one, and the shared hint degrades honestly.

Signed-off-by: asto <asto18089@126.com>
(cherry picked from commit f54583b923f674404e7ea282485fcee7a29aede2)

* fix(agent): disclose the wait timeout bound

The wait schema text said a wait "returns when any one child settles"
without disclosing the finite 30s-default/120s-max block, so a timed-out
wait read as a hang; both surfaces now state the bound and the
timed_out=true receipt shape.

Signed-off-by: asto <asto18089@126.com>
(cherry picked from commit 6197b2398c229fa8fe3c9ef8380d5daff535bcc0)

* fix(agent): use canonical Fleet role vocabulary

The agent tool description still advertised the legacy spellings
(worker/scout/builder/verifier/consultant) while the schema's type field
and the roster use the canonical names, giving the model two words for
one role; the description now lists the canonical names and notes that
legacy aliases remain accepted.

Signed-off-by: asto <asto18089@126.com>
(cherry picked from commit 4c4042382d8a1ce3801f09d5f29c19a4efe0bfb2)

* fix(search): honor locale in bing/ddg scrapes

Keyless Bing SERP scrapes sent no market hint with an English
Accept-Language, so Chinese queries got unrelated Japanese results;
pass mkt/setlang (Bing) and a translated kl region (DuckDuckGo) plus a
matching Accept-Language through the scrape paths.

Signed-off-by: asto <asto18089@126.com>
(cherry picked from commit dda493128336c393e676d7abcea401902d7346ce)

* fix(search): declare scrape locale support

Bing and DuckDuckGo scrapes now honor locale in-request, so their
receipts must report the knob as supported instead of emitting
knob_ignored.

Signed-off-by: asto <asto18089@126.com>
(cherry picked from commit 39eae6929cc08fd37f94675e310619576e591476)

* fix(vision): report real image size metadata

The tool had the original image bytes yet let the vision model guess
dimensions from the picture, so the result now carries header-derived
width/height/format (omitted when the header cannot be parsed) and the
description tells the model to trust that metadata.

Signed-off-by: asto <asto18089@126.com>
(cherry picked from commit 52e5f06efda481e08a767019c6a1387fc931adf3)

* test(mcp): pin boot event failure reasons

The finished McpSessionBoot receipt must keep carrying each failed
server's connection diagnosis so hosts have one engine-side record.

Signed-off-by: asto <asto18089@126.com>
(cherry picked from commit eeffa32cc5466baf52080b6aa738016ef5f0a61d)

* fix(mcp): brief model when boot servers fail

A failed boot previously left the failure invisible to the model for
the whole session, so the briefing rides the existing runtime-handoff
channel once per boot pass into the next turn's context.

Signed-off-by: asto <asto18089@126.com>
(cherry picked from commit 5c7e69a3de6857e2ab932bd6bf68669fcf511352)

* fix(agent): summarize fleet lists per child

The unscoped status/peek fleet listing is a collection, not one child;
passing it through the bounded receipt passthrough hard-truncated every
child past the cap once two running projections exceeded 2,000 chars.
Detect the agents[] envelope and summarize its rows like snapshots,
with a fleet-specific header. Also make the transcript_handle hint gate
structural (field presence, not a substring of the raw JSON), pin the
claim receipt's passthrough classification, and tie the passthrough
bound assertion to the real cap.

Signed-off-by: asto <asto18089@126.com>

* fix(mcp): make boot briefing self-voiding

The boot-failure briefing told the model the mcp_* tools were
unavailable "for this entire session" and banned calling them. But MCP
recovers inside a session: per-turn connect_all retries cooled-down
servers, /mcp retry and reload reconnect on demand, and auth-required
failures put a synthetic mcp_<server>_authenticate tool in the catalog
that the ban told the model never to call. Nothing retracted the
briefing, so a recovered surface stayed shadowed by a startup ban.

The briefing now describes startup only and defers to the live tool
list, and the engine appends a corrective mcp_boot_recovered notice
when a briefed server reconnects through the user-driven retry/reload
seams (which run while idle). The per-turn connect_all refresh relies
on the self-voiding wording instead of a mid-turn append. Briefed
server names are tracked so the notice corrects exactly the servers
the model was told were unavailable, exactly once.

Signed-off-by: asto <asto18089@126.com>

* fix(search): map only verified DDG regions

The generic BCP 47 reversal produced kl values outside DuckDuckGo's
fixed, non-systematic region list (ja-JP became jp-ja; the real value
is jp-jp; kr-kr, tw-tzh, hk-tzh are likewise not reversible), so most
non-English locales silently lost the region signal while the receipt
claimed it was honored. Replace the reversal with the verified pairs
and send no kl at all for anything else. Also derive the Bing setlang
script tag from the market region (zh-TW/zh-HK/zh-MO are zh-Hant, not
zh-Hans), shape-check the model-supplied locale before it reaches URLs
and the Accept-Language header, state the BCP 47 format in the tool
schema, and correct the comments that claimed byte-identical headers
and hid the pure-Han Japanese cost of the Han heuristic.

Signed-off-by: asto <asto18089@126.com>

* fix(subagent): align role vocabulary end to end

The tool description now advertises canonical roles, but the same drift
survived elsewhere: sub-agent briefs still self-identified with legacy
tokens (role: scout/builder/consultant/verifier while every receipt
prints explore/implement/advisor/test), the explore output contract
kept the scout heading, and the resume_from example taught
"explore -> implementer -> verifier". Switch the tokens, heading, and
example to canonical names; keep the legacy aliases parse-accepted.

Also tie the advertised wait bounds to the runtime constants in tests
so constant drift cannot silently falsify the copy, fix the stale
"schema advertises 5" comment, and stop the slash dispatch brief from
naming "the returned transcript_handle" - the default spawn receipt
strips the handle before the model sees it.

Signed-off-by: asto <asto18089@126.com>

* fix(vision): qualify the metadata promise

The description promised width/height unconditionally while BMP (no
decoder feature) and unparsable headers always omit them — the same
unconditional-promise class this PR removes elsewhere. Scope the
promise to when the image header can be read.

Signed-off-by: asto <asto18089@126.com>

* fix(mcp): keep the boot briefing session-scoped

Fresh-eyes audit fixes for the MCP boot briefing. Briefing state was
engine-level while the host hydrates every spawned engine with a one-shot
Op::SyncSession whose restored history can never contain a briefing this
process injected during startup boot: the sync wiped the briefing and the
generation stamp suppressed any re-briefing for the life of the process,
and a stale briefed-server set could fire a corrective notice into a
conversation that never saw the briefing. The wipe was reproduced as a
failing test before the fix.

Op::SyncSession now rebuilds the bookkeeping on its idle seam: it
re-briefs when the failures still hold and the restored history carries
no briefing, and reseeds the briefed set from the persisted briefing
(minus servers the history reports as recovered) when it does. Payload
parsers back the reseed structurally, so a user-authored lookalike is
never matched.

Also from the audit: the boot ban now explicitly exempts the synthetic
mcp_<server>_authenticate recovery tool (an auth-required failure
exposes it from the first turn, so the previous blanket ban contradicted
the tool list in the same request); each diagnosis is bounded to keep a
multi-server briefing compact; the briefing and recovery orderings are
pinned by a multi-server byte-stability test; and the main briefing test
moves under the forkguard_ anchor so the parent-side register batch can
pin it directly.

Replayed MCP handoffs render as system cells instead of user turns.

Signed-off-by: asto <asto18089@126.com>

* fix: receipt, vocabulary, locale, and vision honesty

Fresh-eyes audit fixes across the remaining surfaces.

Agent receipts: the transcript_handle hint fired whenever the raw
receipt carried the field while the row summarizer dropped the value -
a phantom value of exactly the class this PR pins. Summarized rows now
print the handle, and the hint gate also covers fleet-listing rows.
Action receipts (message/followup/interrupt acks, the unchanged nudge,
spawn starts) carry an action key and were snapshot-summarized into
placeholder noise, losing queued/woke/queue_depth/note and continuation
handles; they now pass through bounded like roster/wait/claim, while
the fleet envelope still summarizes per child (matched first). The
2,000-character passthrough cap gains exact-boundary pins.

Vocabulary: the sub-agent brief prose no longer mixes legacy role nouns
("Fleet scout" becomes "Fleet explorer", "for a consultant" and "for a
verifier" become advisor/test-agent, "scout-style" becomes
"explore-style"), and reconciliation role evidence accepts canonical
tokens through the same alias migration the parser uses.

Search: finalize_search_response no longer claims an ignored locale
knob was honored - malformed values and DuckDuckGo regions outside the
verified kl list are flagged KnobIgnored so the receipt keeps the knob
unhonored; the schema says which backend honors what; the
model-visible Web tool schema gains the BCP 47 note; zh_CN normalizes
to zh-CN; an explicit zh-Hant script subtag wins over region-derived
Simplified; Accept-Language stops duplicating the en range for English
markets.

Vision: the promise names stored dimensions, says camera rotation
metadata is not applied, and omits the fields when the container
cannot be sized.

Signed-off-by: asto <asto18089@126.com>

* test(search): align accept-language test with the dedup rule

The round-two dedup change stopped appending a duplicate en range for
English markets but left this test expecting the pre-dedup output for a
"-CN" input that the locale shape check never lets through, so the test
failed deterministically and turned CI red. Pin the reachable bare-language
case instead: locale "en" emits a single range with no duplicate en
fallback, exercising both the no-panic primary extraction and the dedup
rule the DDG path shares with Bing.

Signed-off-by: asto <asto18089@126.com>

* fix(mcp): reconcile sync against boot and delta state

Three review findings, one seam:

A mid-boot SyncSession used to stamp the briefing with a freshly allocated
generation, which advanced the event counter past the in-flight boot and
made every later update of that boot stale: the authoritative error map
never landed and the finish-seam briefing never fired, so a server that
failed to connect stayed unbriefed for the life of the conversation. The
reconcile now leaves the counter alone while the boot is in flight and
lets the finish seam brief with the complete map.

A restored briefing naming fewer servers than currently fail suppressed
the delta forever (and a richer pre-sync briefing was wiped with the
history). The reconcile now re-briefs from the live error map unless the
restored briefing covers every current failure, and the once-per-boot
stamp skips only while the briefing still covers them, so the finish seam
self-heals stale subsets.

A workspace-switching sync dropped the pool but kept its failure
receipts, so the new conversation could be briefed about the previous
workspace's servers; the receipts now clear with the pool.

Red-green: the two new forkguard tests fail against the previous reconcile
(engine.rs stashed) and pass with it; the reseed test's fixture moved to
the covered case, since a live error for a server the history reports as
recovered now legitimately re-briefs.

Signed-off-by: asto <asto18089@126.com>

* fix(agent): print the transcript value the hint names

The round-two row printer read transcript_handle as a string, but every
producer that reaches the summarizer serializes the field as a full
var_handle object (the only string producer, the subagent.failed sentinel,
is an injected event, never a tool result). as_str() was therefore always
None: no summarized row ever printed a transcript value, while the
presence-based hint gate still fired — naming a value the model never
received, the exact phantom class the PR pins. The pinning tests passed
only because they fabricated string handles no producer emits.

Both the row printer and the hint gate now reduce the field to the
session_id/name form handle_read accepts directly, from either the string
or the var_handle shape, and the gate looks through the same snapshot
wrapper the row summarizer unwraps. An empty or shapeless handle suppresses
the hint instead of summoning it. The fixtures now serialize real VarHandle
values, and the claim fixture mirrors the real PersistedWriteClaim shape
(no action key), plus the code comments no longer list spawn starts and
write claims among action-key receipts.

Signed-off-by: asto <asto18089@126.com>

* fix(mcp): flatten control characters in briefing reasons

A failed stdio server's captured stderr is embedded multi-line in the
connection error, and redaction preserves line structure, so the failure
reason carried into the boot briefing could forge '- other_server:' rows
inside the runtime-event envelope and even carry a premature envelope
close — server-controlled prose inside a channel the model must read as
runtime-authored. The reseed parser would then ingest the forged names
into the correction bookkeeping on session sync.

bounded_briefing_reason now flattens control characters to spaces and
collapses whitespace runs before the 280-char bound. Red-green: the new
test asserts the parser reads only the real server from a briefing whose
reason carried hostile multi-line stderr; with the flattening neutralized
it fails.

Signed-off-by: asto <asto18089@126.com>

* fix(search): veto kana and hangul in the Han market fallback

The fallback picks the zh-CN market for a locale-less query containing
any Han ideograph, and kanji sit inside those ranges — so Japanese queries
like a weather lookup for Tokyo were pushed onto the Chinese market with a
Chinese Accept-Language: the inverse of the cross-market drift the
fallback exists to fix. Real Japanese queries almost always carry kana,
and Korean ones hangul, so a Han-bearing query with either script now
keeps the no-market request; only a purely Han-script query (search-rare
and indistinguishable from Chinese) and unmarked Traditional Chinese keep
the accepted fallback. An explicit locale always wins.

Red-green: with the veto disabled the new test fails on the kana cases
and passes with it; the pure-Han fallback test is unchanged.

Signed-off-by: asto <asto18089@126.com>

* fix(search): tighten locale shape and fallback receipt honesty

Three review follow-ups in one seam:

The shape check admitted digit-only tags like 12345, which were then
transmitted as mkt/setlang and receipted with honored.locale=true,
contradicting the schema text promising malformed values are ignored.
The primary language subtag must now be alphabetic and subtags at most
8 characters.

Script+region Chinese tags (zh-Hant-TW, zh-Hant-HK, zh-Hans-CN) resolved
no kl pair even though their region forms are verified, so a model
following the documented BCP 47 style lost the region signal on
DuckDuckGo; they now reduce to the verified region pair.

On the DuckDuckGo-to-Bing fallback the DDG leg's locale KnobIgnored flag
survived even when Bing carried the locale via mkt/setlang, keeping the
receipt on honored=false; the flag is now re-derived from Bing's own rule
by a testable helper (red-green via inverted-retain mutation).

Signed-off-by: asto <asto18089@126.com>

* fix: align remaining reviewed surfaces with the vocabulary and honesty round

Small review-round items across the surfaces the audit touched:

The roster description for the general role still read 'General-purpose
worker' (roster catalog, the seeded legacy-alias roster entry, and the
fleet setup view) — the last model-visible surface teaching a legacy
noun; it now says agent, and the roster receipt fixture quotes the real
FleetRole descriptions instead of fabricated strings.

The two wait faces advertise from independent constant sets, so a numeric
drift between them could pass both per-face pins; a cross-assertion test
now ties the sets together.

The image_analyze description said the metadata comes from the image
container, but the format label is derived from the file extension; the
wording now says so.

The new fork-behavior pins (boot briefing lifecycle, recovery notices,
isolation, drained finish, boot event payload, history rendering, and the
handoff round-trip/sanitization tests) move under the forkguard_ anchor
so the parent-side register batch can pin them.

Signed-off-by: asto <asto18089@126.com>

* style: collapse the reconcile coverage branches and reformat

Signed-off-by: asto <asto18089@126.com>

* fix(agent): print snapshot-wrapped transcript handles in summary rows

The summarizer recursed into the nested snapshot wrapper before reading
the receipt's transcript_handle, so the real SubAgentSessionProjection
shape (handle on the outer envelope, none inside) lost the value: the
presence-gated hint fired while the summary row the model reads never
printed the handle it names — the same phantom-value class the receipt
work pins. Lift the extraction above the unwrap and thread it through
the recursion as a fallback, inner values still winning.

Also gate the hint on the rows that are actually rendered: the summary
prints at most eight rows, and a handle carried only past that cut made
the hint name an invisible value. The gate now scans the same visible
window as the printer (fleet rows included), with the shared constant
pinned to the printer's cut.

Signed-off-by: asto <asto18089@126.com>

* fix(mcp): invalidate the in-flight boot pass on a workspace switch

Op::SyncSession drops the pool and the connection-error map when the
synced conversation belongs to another workspace, but the connect pass
started for the old workspace kept running: its Finished update still
passed the generation check, re-filled the cleared error map, and
injected the old workspace's failure briefing into the freshly synced
conversation. The desktop host's one-shot sync lands while the spawn
boot is still connecting, so the race was reachable on every workspace
switch. Drop the pass's generation, in-flight latch, and update channel
together with the briefing bookkeeping (a same-named server recovering
later must not announce a briefing the new history never saw); late
updates then fall into the stale-generation drop.

The new reload-path recovery test also closes the loopback fixture's
keep-alive hole: it answers one request per socket, so pooled reuse
raced the server's close under load ('connection closed before message
completed'). The SSE response now declares Connection: close.

Signed-off-by: asto <asto18089@126.com>

* fix(text): stop promising deferred-tool hydration by name

Hydration only reaches tools the host allowlist kept in the catalog, so
'registered deferred tools hydrate when called by name' was false
wherever the tool was filtered out entirely. The agent dispatch brief
lost that tail in the receipt work, but the same claim survived in the
goal continuation prompt, the /goal brief, and both skills surfaces,
and one fork pin required the false wording verbatim.

Make every surface honest and uniform: activate via tool_search first,
try the direct call when that cannot surface the tool (it succeeds when
the tool merely sits deferred in the catalog), and only declare the
capability unavailable if the direct call errors. The handle_read
hint gains the same direct-call-first fallback instead of its
over-claiming 'unavailable', the goal pin asserts the mechanism-neutral
shape, and the grep surface for the old claim is now only test
negative-assertions.

Signed-off-by: asto <asto18089@126.com>

* fix(agent): put wait-receipt control fields before the fan-out

serde_json preserves insertion order, so the wait receipts built their
scalar controls (timed_out, all_settled, waited_ms, note) after the
children arrays. A large until=all fan-out blows the 2,000-char
passthrough cap on child rows alone, and head-truncation then dropped
exactly the timed_out=true signal the schema promises unconditionally.
Build the receipts controls-first; the content is unchanged, only the
key order.

Signed-off-by: asto <asto18089@126.com>

* fix(search): keep malformed locales unhonored on the DDG leg

A malformed locale on a pure-Han query rode the Han market fallback
into Some(zh-CN), so the DDG leg reported honored.locale=true — while
the schema promises malformed values are ignored and the Bing leg
flagged the same input honestly. Decide malformed independently of the
market/kl resolution so both legs agree, and correct the rationale
comment: an unmapped locale sends no kl region signal but the
Accept-Language header still carries it.

Signed-off-by: asto <asto18089@126.com>

* fix(session): skip internal runtime handoffs for saved-session titles

Session titles (and their previews, which reuse the title) take the
first role=user message; a session saved before its first real input
would title itself with the truncated MCP boot briefing envelope. Both
title paths now take the first user message that is not an internal
runtime handoff, falling back to the default title when only handoffs
are present.

Signed-off-by: asto <asto18089@126.com>

* fix(mcp): sanitize briefing server names and share the line flattener

Server names are unvalidated config keys; a name containing ': ' or
whitespace was truncated by the line-protocol parser, so its recovery
notice could never fire and every sync re-briefed it. Names are
canonicalized for the line protocol (':' and whitespace become '-'),
and the recovery line uses the same form, so the text round-trips; the
bounded consequences for such pathological names are documented at the
sanitizer.

bounded_briefing_reason also grew as a near-verbatim copy of
cloud_dispatch's one_line. Both now delegate to one pub(crate)
flatten_and_bound_text with a fold-whitespace switch, keeping the
callers' pinned ellipsis-budget semantics.

Signed-off-by: asto <asto18089@126.com>

---------

Signed-off-by: asto <asto18089@126.com>
* fix: stop finite timeouts from killing legitimate work

A timeout-behaviour audit found several finite wall-clock caps sitting on
paths whose work legitimately runs minutes to hours, auto-expiring human
decisions, and local waits with no bound at all. Fix the high-impact set:

- External tool approvals no longer auto-deny after 300s: the decision
  wait is unbounded (human-paced) and ends on turn interrupt or runtime
  shutdown instead, emitting approval.decided{interrupted} so clients
  clear pending UI. The engine's own approval wait was already unbounded;
  request_user_input now waits unboundedly too instead of answering the
  model with a ToolError::Timeout after 300s.
- Background-task idle detection now treats a running tool as progress:
  the journal only records tool calls at start/completion, so the 2-minute
  idle deadline fired during any silent build, test suite, or MCP call.
  The wall-time budget remains the backstop.
- MCP tool calls: the TUI pool default execute timeout 60s -> 1800s and
  the response-read wait derives from max(read, execute) so raising the
  documented knob actually governs; the engine-side stdio proxy gets a
  dedicated 1800s tools/call budget instead of the 120s generic request
  cap; the headless runtime dispatch backstop 300s -> 1800s; MCP OAuth
  browser-callback wait 300s -> 900s.
- js_execution and code_execution: 120s -> 600s and the child is killed
  on timeout instead of being left running orphaned.
- image_analyze vision calls: replace the 120s total deadline with
  connect + per-read idle timeouts; a multi-MB upload plus a long
  non-streaming generation legitimately exceeds two minutes.
- RLM child completions now honor the advertised sub_query_timeout_secs
  (it was stored but never read; the bridge always used its 120s const),
  and inline repl rounds 180s -> 900s to match the model-thinking
  reality the RLM path already accommodates.
- web fetch hard cap 60s -> 300s and the SSRF pre-flight DNS lookup is
  bounded at 10s so a hung resolver cannot stall the fetch envelope.
- Skill/plugin installs get connect + total timeouts (previously none:
  a stalled connection hung installs forever); snapshot git subprocesses
  are bounded at 300s with degrade-on-timeout instead of blocking the
  turn pipeline on a wedged git.
- Non-streaming model requests get a 30-minute total envelope around the
  retry loop (all attempts + honored Retry-After included). The shared
  client intentionally has no client-level total timeout, so compaction,
  translate, and similar non-streaming callers previously had no bound
  against a provider that accepts and stalls, nor against a gateway
  answering 429 + Retry-After: 3600 forever.
- Sub-agent tool timeout default 300s -> 1800s (single source of truth;
  the heartbeat floor derives from it automatically).

Verified: cargo check --tests on tui/mcp/core; targeted suites for
runtime_threads (157), task_manager (46), web (192), mcp (76), core (82),
vision, js_execution, repl, snapshot, skills install, client all pass.

Signed-off-by: asto18089 <asto18089@126.com>

* fix(tasks): keep the idle watchdog honest about in-flight tools

The timeout-audit fix for background-task idle detection tracked running
tools with mismatched keys and only refreshed progress on event arrival:

- item.started carries the engine tool-use id under "tool", but
  item.completed/item.failed only carry the journal item id under
  "item", so the tracking set was never drained: after the first tool
  call the watchdog was disarmed for the rest of the turn (only the
  wall-time budget remained).
- note_progress was evaluated per event, but the very scenario being
  fixed is a tool whose execution window produces no journal events at
  all, so the 2-minute idle deadline still fired during silent builds,
  test suites, and MCP calls.

Track both lifecycle edges by the journal item id, and refresh progress
from the steady-state loop (which wakes at least every catch-up poll)
for as long as any tool item is in flight. The wall-time budget stays
the backstop for a tool that never completes.

Pin both edges with tests: a silent tool survives well past the idle
deadline and the deadline resumes after completion, and a hung tool
still hits the wall budget.

Signed-off-by: asto18089 <asto18089@126.com>

* fix(vision): bound image_analyze with a real total envelope

The audit change replaced the 120s total deadline with connect(10s) +
read_timeout(120s), but reqwest's read_timeout is not a per-read idle
bound for the request phase: its timer starts at send() and is never
reset until the response headers arrive, so it silently re-introduced a
~120s total deadline on the multi-MB upload plus the full non-streaming
generation - the exact healthy work the change meant to protect (the
per-read idle semantics only apply once the response body is streaming).
The accompanying comment described the opposite of the actual behavior.

Bound only the connect handshake on the client and put a 30-minute
envelope (matching the non-streaming model request budget) around the
retry loop and response body consumption, reporting a clear timeout
when it fires. Wiremock tests pin both directions: a stalled provider
is cut off by the envelope with a timeout error, and a prompt answer
arriving in time still flows through.

Signed-off-by: asto18089 <asto18089@126.com>

* fix(snapshot): drain git pipes while the child runs

run_git waited for the child to exit before reading stdout/stderr, so
any invocation producing more than the 64 KiB OS pipe buffer deadlocked
on its own output and died at the 300s command timeout. That is not a
corner case: ls-tree -r -z --name-only on the restore path lists one
path per workspace file, and diff --name-only after a large refactor
grows the same way, so medium-size workspaces would have seen /undo and
snapshot listing stall 300s and then fail - the exact turn-pipeline
blocking the audit meant to remove (the old .output() call drained
concurrently and had no such cliff; the removed 'quiet-output' comment
was wrong about the tree-listing and diff commands).

Spawn two reader threads (mirroring the sandbox exec plumbing in
crate::run_sandboxed_exec) so the pipes are drained while the child
runs, join them on both the success and the kill path, and use a
tighter test-only command timeout so the large-output regression test
fails in seconds rather than hanging.

Signed-off-by: asto18089 <asto18089@126.com>

* fix(mcp): widen the read wait per request instead of clamping the knob

The audit fix set the connection-level read timeout to
max(read, execute) so a raised execute_timeout actually governs a
tools/call. That traded one bug for two more: the read_timeout knob
documented in docs/MCP.md was silently disabled (a user-configured read
budget of 30s or 180s was raised to the 1800s execute default, so fast
requests on a wedged server waited out 30 minutes before failing), and
every non tools/call request on the connection - resources/read,
prompts/get, notifications - inherited the same 30-minute liveness
regression for detecting a dead server.

Restore the connection read timeout to the user's knob and widen the
read wait per request instead: call_method now reads for at least its
own outer budget (so a silent server is not declared dead mid tool
call), while everything else keeps the configured read budget. The HTTP
transport's client-level total stays a separate ceiling computed as
max(read, execute) because it must cover the longest request the
transport carries; both decisions are extracted into named pure
functions and pinned with tests.

Signed-off-by: asto18089 <asto18089@126.com>

* fix(runtime): resolve pending approvals when the engine dies or the runtime shuts down

The unbounded external-approval wait only ended on turn interrupt or
runtime-shutdown cancellation, but two of its documented exits were
not actually reachable. A crashed engine dropped its op receiver while
the wait kept polling for a decision that could never come, suspending
the turn and the pending approval forever (the pre-PR 300s timeout was
accidentally masking this); and nothing in the tree ever cancelled the
manager token, so the shutdown exit was dead code.

Probe the engine's op channel with tx_op.is_closed() - a non-consuming
check - in the wait's poll arm, and give hosts an explicit
RuntimeThreadManager::shutdown() that cancels the token the wait (and
turn monitoring) already observe. Both exits resolve the pending
approval through the same deny+interrupted path as a turn interrupt,
emitting approval.decided so clients clear pending UI.

Pin both exits with tests.

Signed-off-by: asto18089 <asto18089@126.com>

* fix(runtime-api): publish approval interrupted flag over SSE and document resolution semantics

The compat SSE projection forwarded only the legacy timeout field of
approval.decided, so external HTTP/SSE clients could not distinguish a
deny forced by an interrupt or shutdown from a deny the user actually
chose; the projection silently dropped the interrupted flag this PR
introduced. Forward it alongside timeout (which now only ever arrives
from legacy journal replays - as does the approval.timeout event, whose
arm is kept for exactly that reason).

Document the full resolution contract in docs/RUNTIME_API.md: approvals
wait unbounded for a human, and every forced resolution carries
decision deny plus interrupted true while a real user decision never
does.

Signed-off-by: asto18089 <asto18089@126.com>

* fix(tools): close the remaining high-impact timeout outliers

The audit normalized tool execution, human-paced waits, and download
budgets, but four outliers of the same classes survived in code the
audit's own arguments apply to:

- Plugin script tools: the model-invoked interpreters kept the 120s
  cap the audit raised to 600s for js/code_execution, and a timeout
  left the script running orphaned (no kill_on_drop). Align the budget
  and kill the child on timeout, matching the interpreter surfaces.
- Provider PKCE browser sign-in: the callback wait stayed at 300s while
  the identical human-paced argument moved the MCP OAuth callback to
  900s. Apply the same budget.
- Self-update asset download: the total stayed at 300s, which needs an
  implausible >1.6 Mbps to fetch tens-of-MB release binaries - tighter
  than the 600s the audit gives 20 MiB skill tarballs. Use 600s and
  state the arithmetic.
- image_ocr and pandoc_convert ran sync child processes (and blocking
  Vision FFI) directly on the async executor with no wall-clock bound:
  a wedged tesseract/pandoc stalled the executor and the turn
  indefinitely. Bound OCR at 300s (spawn_blocking + timeout, shared by
  the image_ocr tool and read_file's image path) and convert pandoc to
  a tokio Command with kill_on_drop and a 600s timeout so a wedged
  converter is killed instead of orphaned.

Signed-off-by: asto18089 <asto18089@126.com>

* fix(client): bound the non-streaming response body and the Anthropic Messages request

The 30-minute envelope only resolved at response headers: the shared
client has no client-level total timeout, so after a successful response
a provider that trickles the body (a byte every few minutes) still
wedged non-streaming callers - compaction, translate, list-models,
provider-native search - with no per-chunk protection on this path.
Give every attempt in the non-streaming retry loop the same budget as a
reqwest per-request request timeout, which covers connect through body
end, so both the attempt and the whole retry loop are bounded.

The Anthropic dialect's non-streaming Messages request bypasses
send_with_retry entirely (direct send plus a one-shot JSON decode) and
was left completely unbounded - the same accept-and-stall wedge the
envelope fixed elsewhere. Apply the same budget there.

A wiremock test pins the envelope by injecting a short budget through a
test-only override and asserting a stalled provider fails with a
timeout instead of hanging (serialized through the shared test-env lock
because the injected budget is process-global).

Signed-off-by: asto18089 <asto18089@126.com>

* fix(tools): make the interpreter timeout actually kill the child

The kill_on_drop fix relied on the timeout dropping the Command::output()
future to kill the interpreter, but that is not how tokio behaves:
output() moves the Child into wait_with_output(), and dropping that
future mid-wait does not run the kill - a minimal reproduction (spawn
node with kill_on_drop(true), wrap output() in a timeout, let it elapse)
leaves the child alive. So js_execution, code_execution, and the plugin
runner still orphaned their interpreters on timeout; the audit claim
'the interpreter child is killed on timeout' did not hold. Only a plain
drop of an owned Child handle kills via kill_on_drop.

Restructure all three sites: spawn explicitly, keep the Child handle,
drain stdout/stderr with concurrent tasks (so a child blocked on a full
pipe buffer cannot turn the timeout into a guaranteed kill), and kill +
reap explicitly when the deadline fires. kill_on_drop stays as the
cancel-path backstop, where the owned handle really is dropped.

Both interpreters get regression tests that capture the child's pid and
assert it is gone after the timeout (previously these fail: the child
survives the kill).

Also pin the RLM sub_query_timeout_secs wiring with a hanging-child
test asserting the configured budget governs the deadline.

Signed-off-by: asto18089 <asto18089@126.com>

* docs(mcp): refresh timeout defaults and explain the read/execute split

The minimal example still showed the retired 60s execute default, and
neither the field list nor the Chinese reference explained how the
budgets interact: execute_timeout bounds a whole tool call (a server is
silent until its tool finishes, so slow tools raise this knob), while
read_timeout bounds the response wait of quick requests and a tools/call
wait is automatically widened to at least its execute_timeout.

Signed-off-by: asto18089 <asto18089@126.com>

* style: apply rustfmt to the timeout-fix commits

Signed-off-by: asto18089 <asto18089@126.com>

* fix(config): follow the sub-agent heartbeat expectations past the new tool-timeout floor

Raising the sub-agent tool timeout default to 1800s moved the
tool-timeout heartbeat floor (tool_timeout + 30) above two stale test
expectations that predate it: a 900s api timeout no longer dominates
the floor (1830 wins, and that is the point - cleanup must never fire
before a 1800s tool finishes), and the /config subagents status shows
1830 for the api-timeout-0/heartbeat-1 scenario. Update both, keep the
'heartbeat follows a genuinely long api timeout' scenario by adding a
maxed-out api timeout case (3600s api floor wins up to the heartbeat
clamp), and document the dominance in comments.

These surfaced only now because the original commit's compile break
masked every test behind it; CI's full-workspace run caught them.

Signed-off-by: asto18089 <asto18089@126.com>

* fix(client): keep per-attempt request totals off the streaming open path

The per-attempt reqwest total added to send_with_retry covers connect
through body end — which is exactly why it must not apply to streaming
opens: reqwest wraps the response body in TotalTimeoutBody, so the total
rode on the returned body and hard-cut any live stream 30 minutes after
send, on the default ChatCompletions dual-stack open and on every
Responses open. The old comment claimed the envelope was inert under the
callers' 45s open budget; that budget only bounds the open, not the body
that outlives it.

Stream opens now go through send_stream_open_with_retry (and the JSON
twin open_stream_json_with_retry), which sets no total and skips the
loop envelope; their bounds stay the caller's stream_open_timeout plus
per-chunk idle checks. list_models moves to send_with_retry_total so its
pinned 30s survives — .timeout() on the builder is a pure overwrite, so
the shared envelope silently stretched it to 1800s before — and the
loop envelope now dominates the per-attempt total it wraps instead of
strangling a larger pinned budget.

Wiremock regression tests pin both: a stream open answering past the
injected envelope must succeed, and a caller-pinned total must not be
overwritten.

Signed-off-by: asto18089 <asto18089@126.com>

* fix(runtime): exclude the user-input human wait from the turn wall clock

The unbounded request_user_input wait claimed to mirror the approval
wait, but only the approval wait pauses the per-turn wall-clock budget
(begin_human_wait had exactly one non-test caller). An answer submitted
past the budget was collected and then discarded: the next provider
boundary saw an exhausted budget and failed the turn — the exact
failure class this PR removed for approvals, now with an unbounded
burn instead of the old 300s cap.

Wrap the wait in begin/end_human_wait like await_tool_approval, and pin
it with an engine test: a 1s budget plus a 1.2s-delayed answer must
complete the turn and reach the second model request.

Signed-off-by: asto18089 <asto18089@126.com>

* fix(tools): return from timeouts even when grandchildren hold the pipes

Killing the direct child closes only its pipe write ends. A grandchild
that inherited them (a background process a script spawned, a
post-checkout hook, a gc pack worker) keeps them open, so the drain
tasks' read_to_end never sees EOF — and the timeout arms joined those
tasks, hanging the caller past the 600s/300s bound and re-introducing
exactly the wedge the bounds were added to remove. The old
join-cannot-hang comments asserted the opposite of the real pipe
semantics; run_git's comment also cited a helper that does not exist
(crate::run_sandboxed_exec; the mirror is run_sandbox_command, whose
timeout path in fact detaches rather than joins).

All three interpreter timeout arms now abort the drain tasks instead of
joining them (dropping the read ends hands grandchildren an EPIPE), and
run_git detaches its reader threads on the timeout path. A js regression
test spawns a pipe-holding grandchild and pins the prompt return: the
join implementation measured 30.0s (the grandchild's full sleep) against
the 5s test budget.

Signed-off-by: asto18089 <asto18089@126.com>

* fix(sandbox): bound the two unbounded external-request paths the audit missed

Two paths sit outside the timeout fixes this PR already shipped while
carrying the exact same failure modes:

- The OpenSandbox backend hardcoded a 30s client-level total for the
  HTTP exec — and that request IS the remote command, so any remote
  build or test run past 30s was cut off at the HTTP layer, with no way
  to raise it. 600s now matches the Bash tool's foreground cap for the
  same class of work, under a named constant.
- The app-server OpenAI-compatible proxy built its upstream client from
  the shared platform builder, which sets no timeouts at all: on a
  non-streaming forward, a provider that accepts the connection and
  stalls (or trickles the body) wedged the handler — the accept-and-
  stall wedge fixed in the TUI client. Bound the connect at 10s and the
  whole round trip at 1800s, mirroring the TUI non-streaming envelope.

Signed-off-by: asto18089 <asto18089@126.com>

* fix(snapshot): clear a stale side-repo index.lock on open instead of failing forever

Snapshot git commands are bounded at 300s, and the timeout path SIGKILLs
the child — which skips git's own lock cleanup. A killed `git add -A`
or `checkout` (the commands most likely to hit the bound) leaves
index.lock behind, and every later snapshot then fails fast on the lock:
the degrade-to-disabled story silently becomes permanently-disabled,
with nothing user-visible pointing at the cause.

On open, remove an index.lock older than one hour (matching the stale
tmp_pack bound; no live git of ours can hold it past the command bound)
and warn, since its presence means snapshots were silently failing.
Fresh locks are left alone. Covered by a stale-vs-fresh regression test.

Signed-off-by: asto18089 <asto18089@126.com>

* fix(tools): close the small gaps the review round flagged

- task gate runs: kill_on_drop on the gate command, so a timed-out gate
  child is not left running orphaned (the same bug the interpreter tools
  fixed; the gate kept the plain timeout(cmd.output()) shape).
- fetch_url schema: the timeout_ms description still said max 60,000
  after the hard cap moved to 300s, so the model-visible contract
  understated the ceiling it clamps to.
- rlm recursion: the nested bridge built for rlm_query never inherited
  sub_query_timeout_secs, so the knob governed only the top-level
  bridge's llm_query calls; it now follows the recursion. The parameter
  is mandatory at every run_rlm_turn_inner call site, which keeps the
  wiring enforced.
- plugin scripts: the execution timeout is now 5s under cfg(test) like
  the js/code interpreters, and the timeout kill is pinned by a
  pid-liveness test (the PR claimed pid tests at all three interpreter
  sites; plugin was the one missing it).
- shutdown(): the doc now states what the token actually resolves —
  approvals only — and that there is no production caller yet, instead
  of implying all suspended waits are covered.
- SSE projection: regression coverage for the interrupted flag and the
  legacy approval.timeout replay arm.

Signed-off-by: asto18089 <asto18089@126.com>

* style: satisfy clippy on the rlm recursion threading

Signed-off-by: asto18089 <asto18089@126.com>

* fix(tasks): feed the worker idle watchdog from the in-flight tool window

The in-flight-tool suppression added for silent builds only refreshed
drive_engine_turn's own guard. The worker supervisor (run_task) wraps the
executor in a second ExecutionGuard fed exclusively from the task-event
channel, where a silent tool produces nothing after ToolStarted — so on
the only production path (automation tasks through
EngineTaskExecutor::execute) the 2-minute idle deadline still fired and
cancelled the task. The previous tests all drove drive_engine_turn
directly and structurally could not see the supervisor.

Publish the in-flight window as a ToolHeartbeat task event while
running_tools is non-empty: the supervisor counts it as progress, and it
is deliberately not persisted or surfaced on the timeline. The seam is
pinned from both sides — the runtime loop must emit the heartbeat, and
the supervisor must count it — each side fails if its half is reverted.

Signed-off-by: asto18089 <asto18089@126.com>

* fix(tests): gate the code-execution pid test to unix

The pid-liveness assertion polls the process via libc, which is only a
dependency on unix targets, unlike the js/plugin kill tests that carry

Signed-off-by: asto18089 <asto18089@126.com>
#[cfg(unix)]. Ungated, the Windows test leg fails to compile.

* fix(tools): bound the interpreter pipe drain after a clean exit

The budget wrapped only child.wait(): after a normally-exiting child,
the stdout/stderr drain joins on the success path were unbounded, so a
grandchild that inherited the pipes (the old 'npm run dev &' pattern)
held the tool call past the deadline — the old timeout(output()) shape
bounded the whole operation, so this was a regression introduced by the
explicit-kill restructure. The timeout arms already aborted their drains
for exactly this reason; the success arm now applies the same rule with
a short grace: a clean child sees EOF immediately, and only an
inherited pipe can hold it, in which case the captured output is
returned with a note on stderr instead of truncating silently.

The three near-identical interpreter blocks (js, code, plugin) and
their byte-identical drain helpers collapse into one shared
tools::process::run_bounded_child, which also writes the plugin stdin
payload and aborts it on the timeout path instead of leaving it
detached.

The comments also stop claiming that dropping a wait_with_output()
future does not kill the child: on the pinned tokio the ChildDropGuard
does kill on drop. The real reasons for holding the Child explicitly
are a synchronous, testable reap and the ability to return promptly at
child exit instead of coupling the deadline to pipe EOF.

Signed-off-by: asto18089 <asto18089@126.com>

* fix(snapshot): route every git call through the bounded drain core

git init and the date-pinned commit-tree ran as bare .output() calls
with no timeout — commit-tree is reachable from the per-turn prune
path — while the stale-lock doc comment claimed every git invocation
in this module is bounded. Both now go through the same bounded core as
run_git, making that comment true.

The core itself closes two ways the 300s bound could be escaped: the
success-path readers are joined under a short grace (a hook or gc pack
worker that inherited the pipes could otherwise hold read_to_end, and
with it the turn pipeline, indefinitely — partial output is returned
with a note on stderr), and the kill's reaping wait moves to a detached
thread, because on a hard-wedged mount git dies only when its
uninterruptible syscall returns and a blocking wait() would hang the
pipeline exactly like the wedged git would.

Readers stream into shared buffers so a grace expiry keeps what was
captured instead of losing it with the thread. A timed-out git also
reaches the user's stderr through the once-per-workspace snapshot
notice now — the fresh index.lock it leaves behind fast-fails
snapshots for about an hour, and that must not be log-only.

Signed-off-by: asto18089 <asto18089@126.com>

* fix(app-server): bound the runtime bridge client and event stream

The bridge client was built from the platform builder with no timeouts
at all — the same shape commit b32fc69 fixed for the upstream proxy
in this crate, one module away. The inner bridge lock is held for the
whole turn, so one request to a live-but-wedged runtime child queues
every later JSON-RPC message behind it forever.

The client gets a 10s connect budget; the bridging POSTs (enqueue and
return by design) get a 1800s total matching the TUI non-streaming
envelope; and the SSE event stream gets a 60s per-chunk idle bound that
re-arms every chunk — the runtime emits 15s keepalives, so this catches
a wedged stream without total-capping a live one (a per-request total
would ride the body and hard-cut long turns, the same trap the model
client's stream-open path avoids).

Signed-off-by: asto18089 <asto18089@126.com>

* docs: sync operator docs and comments with the current wait semantics

The heartbeat docs still resolved the default to 630s from
api_timeout_secs alone and never mentioned the tool-timeout floor the
code has applied since the sub-agent tool timeout moved to 1800s;
operators reading SUBAGENTS.md or CONFIGURATION.md compute 630 and
observe 1830 from /config with no documented rule. Both languages now
state both floors.

RUNTIME_API.md presented runtime shutdown as an active approval
resolution path, but RuntimeThreadManager::shutdown has no production
caller yet — the contract is now stated as latent until a host wires
it up. The MCP OAuth comment in handlers.rs still said five minutes
after the wait moved to fifteen.

Signed-off-by: asto18089 <asto18089@126.com>

* fix(mcp): give the send leg the same per-request budget

call_method awaited self.send outside every budget: a server that
wedged without draining stdin blocked write_all forever — the same
liveness hole the per-request read budget closed on the receive side.
The send now shares the request budget; a timed-out partial write
desyncs the line protocol, so it routes through finish_guarded_error
like the read-side timeout and the caller reconnects.

Two wiring tests pin the receive leg's widening and the send bound at
the call_method level, not just in the pure functions: a server that
answers slower than a small read knob but well inside a raised
request budget must survive, and a stalled write side must hit the
budget with a send-specific error.

Also make the 900s OAuth browser wait consistent everywhere: the tool
description and two comments still said five minutes, and the
synthetic-tool test pinned the old wording.

Signed-off-by: asto18089 <asto18089@126.com>

* fix(client): widen the injected test envelope against parallel load

The non-streaming envelope tests inject a process-global 250ms budget;
tests that issue real non-streaming requests without holding the test
env lock can run concurrently with that window, and a localhost round
trip over 250ms under CI load fails them spuriously. Inject 2s with
scaled server delays instead — the pins are unchanged, only the
safety margin.

Signed-off-by: asto18089 <asto18089@126.com>

* fix(sandbox): connect-bound the OpenSandbox exec client

The 600s total arrived without a connect timeout, so a black-holed
host ate the whole exec budget stalling in TCP connect while every
other client this PR bounds fails on the 10s connect family.

Signed-off-by: asto18089 <asto18089@126.com>

* fix(tools): restore the pandoc tool docs and complete the OCR disclosure

The PANDOC_TIMEOUT constant was inserted between PandocConvertTool and
its doc comment, leaving the tool undocumented and the rustdoc lost.
The image_ocr timeout disclosure now also mentions the orphaned
tesseract child a timed-out call leaves behind, not just the blocked
thread.

Signed-off-by: asto18089 <asto18089@126.com>

* fix(snapshot): make git pipe readers cancellable

A drain-grace expiry on the success path dropped the reader
JoinHandles, and the timeout path detached them outright: each reader
thread stayed blocked in a pipe read for as long as a grandchild held
the write end, so a hook or subprocess that daemonized leaked one OS
thread per pipe per git call. The regression only backgrounded a
sleep, so the leak self-healed after 30s and the coverage hole went
unnoticed.

The drain loop is now cancellable on both platforms — a non-blocking,
polled read on Unix, a PeekNamedPipe probe loop on Windows — and both
exits cancel and join the readers, so the joins are bounded by one
poll interval and the call provably leaves no reader threads behind.
A live-reader counter makes the join observable to the new regression,
which drives two calls with a never-exiting grandchild and fails
('4 still running') if the join reverts to a detach.

Signed-off-by: asto18089 <asto18089@126.com>

* fix(snapshot): pass git paths via env, not argv

The refactor that routed run_git through the bounded drain core moved
the --git-dir/--work-tree sinks, and code scanning re-flagged the
unchanged flow as a new command-line-injection alert, turning the PR
gate red on a finding that already exists as two open alerts on
pinvou3-clean. Both paths now ride git's documented GIT_DIR /
GIT_WORK_TREE environment interface — the same mechanism and the same
never-touch-the-user's-repo guarantee, with the paths out of argv so
the accepted flow stops re-flagging on every refactor.

Signed-off-by: asto18089 <asto18089@126.com>

* fix(runtime-api): restore only known snapshot ids

CodeQL's PR analysis kept re-flagging run_git because the flow it
reports starts at the /v1/snapshots/{id}/restore route: the request
path id reached git as a treeish, so every refactor of the sink
re-registered the finding instead of matching base alert codewhale-hq#198. The
restore endpoint now resolves the id against the snapshots the side
repo actually knows and 404s on anything else — the allowlist contains
is the scanner's modeled trust boundary, and unknown ids no longer
reach a git command line at all.

Signed-off-by: asto18089 <asto18089@126.com>

* fix(tasks): keep liveness heartbeats out of the persist path

ToolHeartbeat was missing from the persist-urgent exclusion list, so
every ~200ms heartbeat during a silent build rewrote the whole task
record while holding the manager-wide state lock — contradicting the
variant's own 'never persisted' contract. Also comment the disclosed
compaction blind spot at the in-flight tracking set.

Signed-off-by: asto18089 <asto18089@126.com>

* fix(mcp): poison the connection on request-leg timeouts

A timed-out send can leave a partial line in the pipe, and a timed-out
outer read wait can still be answered late — both desync the line
protocol for the next request, yet neither path marked the connection
Disconnected, so the pool handed the broken transport right back out.
Both arms now mark it dead (matching the inner read timeout) and the
comment states the real behavior; wiring tests pin the state.

Signed-off-by: asto18089 <asto18089@126.com>

* fix(snapshot): surface session failures through the stderr notice

The once-per-workspace snapshot notice was wired only to open_or_init
errors, so the motivating case — a wedged 'git add -A' timing out and
leaving a fresh index.lock — stayed log-only, the exact silence the
notice exists to prevent. Session and prune failures now reach it too;
the TimedOut kind gate keeps it to the cases that matter.

Signed-off-by: asto18089 <asto18089@126.com>

* fix(snapshot): ignore ambient GIT_DIR for the side-repo init

git init was the one command not cleared of an ambient GIT_DIR /
GIT_WORK_TREE exported by the launching shell, which could redirect the
one-time init away from the hashed side-repo path; every later command
would then fail against the intended repository. Also fixes the stale
comment that still described flag-based path routing.

Signed-off-by: asto18089 <asto18089@126.com>

* fix(tools): let plugin callers detect a truncated drain

The drain-grace truncation note lands on stderr, but the plugin surface
reports stdout only, so an unparseable, possibly cut-off output passed
as a silent success. The note is now a shared constant with a probe,
and the plugin arm fails loudly when the output did not parse and the
drain was truncated.

Signed-off-by: asto18089 <asto18089@126.com>

* fix(runtime): honor a queued approval decision over cancel

The wait's biased select favors the cancel token, so a decision already
queued at the instant of an interrupt or shutdown was discarded and
reported as an interrupted deny — contradicting the documented contract
that a decision the user actually made never carries interrupted. Both
exit arms now rescue a queued decision via try_recv; a test pins the
race deterministically.

Signed-off-by: asto18089 <asto18089@126.com>

* fix(app-server): bound the runtime stream header wait

A runtime child that accepts the bridge's event-stream GET but never
writes response headers held the bridge lock forever — the queue-forever
scenario the client bounds were meant to remove, surviving through the
headers phase. The header wait now shares the 60s chunk idle deadline;
the live stream itself keeps no total.

Signed-off-by: asto18089 <asto18089@126.com>

* fix(sandbox): correct the exec budget rationale

The 600s constant claimed to match the Bash tool's foreground cap, but
the Bash cap is effectively unbounded — the real parity class is the
interpreter tools' 600s execution cap. State that, and note the bound
is a transport backstop rather than a per-command policy.

Signed-off-by: asto18089 <asto18089@126.com>

* fix(tests): widen the human-wait wall-clock margin

The 1s budget against a 1.2s answer left the engine's pre-pause work a
hair of headroom on a loaded CI host. The margins move to 2s/3s; the
exclusion semantics the test pins are unchanged.

Signed-off-by: asto18089 <asto18089@126.com>

* docs: sync stale wait wording and the restore 404 contract

The MCP OAuth login comment still said five minutes (now 900s), and
SUBAGENTS.md presented tool_timeout_secs as a resolvable config key
when only api_timeout_secs exists — the tool timeout is a compile-time
constant. RUNTIME_API.md now states the restore endpoint's
exact-match/404 contract.

Signed-off-by: asto18089 <asto18089@126.com>

---------

Signed-off-by: asto18089 <asto18089@126.com>
…T7) (#65)

* feat(subagent): list host-presented profiles in agent roster action (T7)

The agent tool's action=roster only ever returned the 8 builtin Fleet
roles, while the base contract tells hosts to present exact prompt-only
profile ids themselves — leaving embedder-injected profiles (Pinvou's
expert cards) undiscoverable by the model. The roster payload now adds
host_profiles (sorted by member id, capped at 48 with
host_profiles_truncated and host_profile_count), listing exactly the
members profile=<member_id> can resolve: the acceptance contract
(config origin, prompt-only, no built-in role-token shadow, bounded
selector) is single-sourced in is_spawnable_host_profile and shared by
the listing and spawn resolution, so the roster never advertises an id
spawn would refuse. Builtin members keys are unchanged and the payload
stays stable when no host profiles exist.

Tests: forkguard_agent_roster_lists_only_spawnable_host_profiles pins
that the listing mirrors spawn acceptance (mixed origins, route pins,
role-token shadows, oversized selectors);
agent_roster_lists_host_presented_profiles_spawn_resolves;
agent_roster_truncates_host_profiles_at_the_listing_cap; the existing
roster action test pins the new empty-shape keys.

Signed-off-by: asto18089 <asto18089@126.com>

* feat(subagent): let roster profile_query filter host profiles (T7)

The roster host-profile listing caps at 48 rows and the agent schema has
no pagination, so embedding hosts presenting more spawnable profiles than
the cap left their tail permanently undiscoverable: the model could see
host_profiles_truncated=true but had no way to reach the dropped ids, and
profile=<member_id> requires knowing the exact id. Host-review follow-up
on the swarm phase 2 parent PR.

Add an advertised, roster-scoped profile_query keyword: a bounded,
case-insensitive substring match over member ids and the same description
the listing displays (matched before output bounding). The filter narrows
the spawnable pool before the cap; host_profile_count and
host_profiles_truncated describe the filtered set, so a zero-match answer
stays honest. Blank or non-string values filter nothing. The advertised
surface grows 12 -> 13 fields; budgets, routing and workspace knobs stay
off (pins updated).

Tests: the 13-field schema pin, the roster dependentSchemas branch
advertising profile_query, and a discovery regression that builds 50
spawnable profiles, proves exp-host-049 is cut from the default listing,
found by description and id keywords, honest on zero matches, ignored on
blank input, and resolvable at spawn via profile=.

Signed-off-by: asto18089 <asto18089@126.com>

* test(subagent): close review-pinned coverage gaps (T7)

Fresh-review follow-ups on the roster/profile_query wave: the forkguard
shadow mirror now resolves against a roster that actually contains the
renamed role-token member, so FleetRole::from_str-over-roster precedence
is load-bearing (a roster without the id takes the role path under any
ordering); the discovery regression pins partial-id substring matching
so an exact-equality regression on the id leg fails; the ROSTER_PROFILE_
QUERY_MAX_CHARS doc now states the truncate-not-refuse convention it
actually implements; docs/FLEET.md cross-reference follows the advertised
surface to 13 fields.

Signed-off-by: asto18089 <asto18089@126.com>

* fix(fleet): dedupe host ids that get() conflates

[fleet.profiles] is a byte-order BTreeMap, so case-variant and
whitespace-variant keys (Expert / expert / ' expert') are distinct legal
entries while FleetRoster::get resolves all of them to the first member
in order. The host roster built by from_host_config listed every key
verbatim, so the roster action could advertise an id whose selection
silently spawned a different profile's prompt — every other profile
layer (directory loaders, ambient merge) already fails closed or shadows
on exactly this collision.

Collapse each duplicate to the member get actually resolves to, and pin
the listing/spawn invariant with a case/whitespace collision regression:
every advertised member_id must resolve back to itself.

Signed-off-by: asto18089 <asto18089@126.com>

* fix(fleet): collapse conflatable config ids at load too

The roster listing and the spawn path build the [fleet.profiles]
layer through two independent constructions. The listing reads the
engine-installed config.fleet_roster, built by FleetRoster::load,
whose merge_member folds each config key into the merged roster
last-wins (byte-order-last key replaces its conflatable siblings in
place). The spawn path re-resolves profile=<id> against a roster
rebuilt per spawn by refresh_spawn_route_sources via
FleetRoster::from_host_config, which 0576deb teaches to collapse
ids get() conflates (trimmed, ASCII case-insensitive) FIRST-wins.
For case-variant config keys such as 'Expert' and 'expert' — both
legal, distinct TOML keys — load advertised the last key's member
while get() on the spawn roster resolved every spelling to the
first one: the roster could list a profile whose prompt silently
differs from the one a selection actually spawns.

The invariant was previously established only on the from_host_config
side; both regressions (agent_roster_never_lists_ids_that_spawn_a_
different_profile, from_host_config_collapses_ids_that_get_resolves_
together) constructed their rosters through from_host_config
directly, so the listing/spawn pairing gap could not surface.

Extract the first-wins collapse (and the config-entry-to-member
mapping) into shared helpers and apply the same collapse to the
config-extracted profile list in load before merge_member, so the
last-wins layer merge never sees two conflatable config keys.
Cross-source precedence is preserved exactly: config entries still
override built-ins and are still overridden by plugin, personal, and
workspace layers; only intra-config duplicates are removed, in the
same direction from_host_config already pins.

Regressions:
- agent_roster_listing_and_spawn_agree_on_conflatable_config_ids
  builds the listing side the way the engine does (FleetRoster::load)
  and drives the spawn side through the real refresh_spawn_route_sources,
  asserting the listed row is the member spawn resolves, with the same
  id, description, and prompt overlay. Verified to fail on the
  pre-fix code.
- load_collapses_conflatable_config_ids_like_from_host_config pins
  the load-side direction at the roster unit level.
- roster_host_profile_listing_cap_is_pinned_at_48 literal-pins
  ROSTER_HOST_PROFILE_LIMIT to the 48-row cap the app-side roster
  contract teaches.

Signed-off-by: asto18089 <asto18089@126.com>

---------

Signed-off-by: asto18089 <asto18089@126.com>
* fix(tui): 恢复历史隐藏内部运行时 handoff,保留 MCP system cell 例外

吸收上游 6362e1e 的通用过滤:history_cells_from_message 在
restored checkpoint 分支之后接入 is_internal_runtime_handoff,
background shell completion 等运行时 handoff 恢复后不再渲染为
User cell(fork 同样存在该 bug)。过滤仅作用于显示层,持久化与
模型侧 api_messages 逐字节不变。MCP briefing/recovery 在更早分支
已按 fork 决策返回 System cell,不会落入新过滤器,行为保持。

测试按上游语义改写并新增:
- apply_loaded_session_never_restores_background_shell_event_as_composer_draft
  覆盖 current/legacy 两种 provenance、保存与会话消息字节不变、
  恢复后 cell 序列精确为 [User, Tool, Assistant];
- 新增 apply_loaded_session_keeps_user_authored_shell_event_lookalikes:
  无 provenance 的用户手打 envelope 形似物仍可见;
- 新增 restored_background_shell_completions_yield_no_cells_but_user_text_survives:
  单元级钉住两种 provenance 隐藏、普通用户文本幸存;
- backtrack 回归测试追加 handoff 不计为用户发言的断言。

红→绿:改写后的 resume 回归、单元级隐藏断言与 backtrack 断言在
改动前均失败,改动后通过;forkguard MCP system cell 测试全程通过。
验证:cargo fmt -p codewhale-tui;cargo clippy -p codewhale-tui
--lib -- -D warnings 通过;history::/ui::/runtime_handoff::/
session_manager::/session_peek::/compaction:: 共 924 项通过;全量
lib 11849 项通过(4 个 remote_control/runtime_threads 用例因共享
runtime 目录在并行下环境性失败,单独运行通过,与本次改动无关)。

Signed-off-by: asto18089 <asto18089@126.com>

* fix(tui): read 结果按预算自限后不再被上下文压缩器二次截断

移植上游 e7f7c71 的 context 压缩层豁免守卫。read 工具按字节预算自我
截断并以页脚声明"从 offset 续读",但压缩器硬限 12,000 字符会把打满预算
的 read 结果再做一次 head/tail 压缩,内容被切、续读契约丢失。

- compact_tool_result_for_route:metadata 带 read_budget_bytes 且 raw
  字节长度不超过预算时原样放行(位置与上游一致:evidence_available
  早退之后、subagent 摘要器之前);超出自身预算仍走常规压缩。
- read 工具两条物理路径在受预算约束的成功结果上写入 read_budget_bytes:
  execute_contract_read(read 原语,50KiB 预算)与 render_line_window
  (read_file 遗留阅读器,16KiB 预算)。值取最终输出实际字节长度:
  窗口预算约束的是内容,续读页脚与 <file> 包装叠加其上,只有按最终
  payload 声明,守卫条件才对打满预算的结果成立。
- 测试:engine 层验证带 metadata 原样放行、无 metadata 与超预算仍压缩;
  file 层两条路径各验证打满预算的结果携带 metadata 且过压缩器后页脚
  完整存活(端到端)。

验证:cargo fmt;cargo clippy -p codewhale-tui --lib -- -D warnings 通过;
cargo test -p codewhale-tui --lib -- core::engine::tests tools::file
531 通过 0 失败;contract_read read_budget budgeted_read 过滤 7 通过。
改动前 3 个新测试均红(被二次压缩 / 缺 metadata)。

Signed-off-by: asto18089 <asto18089@126.com>

* fix(tui): 行摘要器输出有界的 child 路由回执

上游 0c03b5a 在 emitter 侧让每行 compact status 携带有界化的 typed
child_route,fork 的 subagent 行摘要器此前丢弃该字段,unscoped fleet
监视面上 child 实际使用的 provider/model 路由到不了模型。

- summarize_subagent_snapshot 每行在 receipt 可打印时输出 route 行:
  provider/model,附 route_source 与 resolved_profile_id(连接身份);
  字段缺失或形状不符时静默跳过,不打印空占位。
- 有界:各字符串字段 preview 截 64 字符,整行硬性守卫 200 字符,不
  依赖 emitter 侧 compact_child_route 的 ≤1024 字节保证。
- 兼容三形状:对象 receipt、裸 "provider/model" 字符串、snapshot
  wrapper 包裹(wrapped 行优先,envelope 兜底,与 transcript_handle
  的 fallback 规则一致)。

验证:cargo fmt -p codewhale-tui;cargo clippy -p codewhale-tui --lib
-- -D warnings 通过;RUST_MIN_STACK=16777216 cargo test -p codewhale-tui
--lib -- core::engine::tests 381 通过(新增
forkguard_fleet_summary_rows_carry_bounded_child_route 与
forkguard_subagent_projection_child_route_reaches_summary_row,
先红后绿)。

Signed-off-by: asto18089 <asto18089@126.com>

* style: 自检微调——守卫注释改 fall-through 表述,route 行缺失段不再打占位

Signed-off-by: asto18089 <asto18089@126.com>

---------

Signed-off-by: asto18089 <asto18089@126.com>
* fix(mcp): stream session boot, make tool_search boot-aware

The session-boot connect pass returned only after every pending server
settled, and only then stored results into the pool. Two slow remote
401s could therefore hold the local command servers' tools off the
shelf for the whole boot window (~88s in the field), while tool_search
consulted a turn catalog built from the empty pool — and a bare empty
result is indistinguishable from "no server configured", so a
forced-capability request issued shortly after launch collapsed into a
definitive "capability unavailable" verdict even though the capability
came up moments later.

Three coordinated seams:

- boot: `spawn_pending_connects` returns the unfinished JoinSet and the
  session-boot task applies each result under a short pool lock the
  moment it settles — first-settled-first-available — sending Progress
  per settle; `connect_all` keeps the drained batch wrapper.
- turn loop: while boot is in flight, each tool batch folds servers
  that became ready since the turn started into the live catalog
  (append-only, deferral mirrored from the turn build), so an in-turn
  tool_search retry can find them. The boot update channel is left to
  the idle seam, which owns the failure briefing — it must never be
  appended mid-tool-loop.
- tool_search: while boot is in flight the result carries an `mcp_boot`
  block naming the servers still connecting, and the tool description
  states the retry contract, so a miss reads as "not yet", never as
  "absent".

A settled boot keeps every existing contract byte-compatible: the
payload carries no booting block, the refresh is a no-op, and the
drained batch shape remains for `connect_all`.

Tests use a real stdio fixture server: fast-server tools become
snapshot-able while a hung server is mid-timeout (mutation-checked
against the batch-return shape); the refresh append/deferral/
no-duplicate contracts; the connecting-status truthfulness boundaries;
and the settled-boot payload shape.

Signed-off-by: asto <asto18089@126.com>

* fix(mcp): narrow boot appends and trust pool diagnosis

Review of 79853e3 surfaced four MAJOR seams the first cut left open;
this closes them without changing the streaming shape.

- The boot-window catalog refresh now applies the same narrowing the
  turn build does: the MCP feature and tool-security gates, the turn's
  ToolSurfacePolicy allow/deny predicates, and apply_mcp_tool_deferral
  itself for the deferral treatment. A tool the build stripped can no
  longer re-enter mid-turn, where a search hit would defeat the
  denied-name concealment and a declared activation would serialize an
  operator-excluded schema into the request.
- An always_load append now resolves eager and joins the active set,
  with the caller declaring the growth through the existing
  tool_surface re-pin; a turn starting while boot is in flight stamps
  "mcp-session-boot" up front, so the next turn's snapshot picking up a
  newly settled tool is a declared re-pin instead of undeclared drift
  tripping the C5 guard (the stamp is inert when nothing changed).
- The connecting status reads the pool's live truth: catalog_authorized
  servers count as answered (wider than is_ready, matching all_tools)
  and connect_backoff entries count as diagnosed, so a server that
  already settled with a failure is never reported "still connecting"
  before the queued boot update is drained; collect_pending_connects
  drops the backoff entry when it re-pends a server, preserving
  "backoff entry => no attempt in flight".
- The booting note and comments now describe what the code does, the
  JoinError attribution lives in one helper, and the misspelled test
  name is fixed.

New tests drive the turn loop end to end (search finds the refreshed
tool and carries the boot note; the boot-window turn start declares its
surface change), plus policy/security, always-load, pool-side-diagnosis
and drain-lag regression tests.

Signed-off-by: asto <asto18089@126.com>

* fix(mcp): keep the retry ladder and close gate gaps

Round-2 review of the branch surfaced one consumer regression and two
boot-window gate gaps; this closes them without changing the streaming
shape.

- collect_pending_connects marked a re-pended server by dropping its
  backoff entry, which also reset the failure ladder: every automatic
  reconnect cycle restarted a dead server at the 30s base instead of
  climbing toward the 600s cap, and mcp_tools' per-turn connect_all
  paid a full spawn+handshake for each reset. The entry now survives
  the re-pend and a connects_in_flight set marks the attempt, so
  settled_failure_names still splits "diagnosed, cooling down" from
  "connecting right now" — and the ladder climbs.
- The tool_search seam re-folds the live pool before searching: a
  server that settled while earlier tools of the batch were executing
  used to produce a bare miss with no note at all, because the status
  correctly stopped naming it while the batch-start fold had not
  picked it up yet.
- mcp_boot_search_status now applies the same MCP feature and
  tool-security gates as the refresh: a tool-security turn's catalog
  has no MCP surface, so its search must not carry a connecting note
  for servers that turn can never reach.
- The boot re-pin declaration is first-set wins across all three boot
  stamps (turn start, Finished, drain): an already-pending `resume` or
  `tool_surface` reason keeps ownership instead of being washed into
  `mcp-session-boot`.
- allows_tool is promoted out of #[cfg(test)] so the refresh and the
  build spell the narrowing in one place, the batch wrapper's doc
  describes its actual caller, and two overreaching comments (the
  backoff entry's "exactly while no attempt is in flight" claim and
  drop_all_connections' settle guarantee) are rewritten around what
  the code actually does.

Four new tests pin the behavior fixes and were mutation-checked:
restoring the entry drop, deleting the search-time re-fold, removing
the status gate, and overwriting an already-declared reason each turn
the matching test red.

Signed-off-by: asto <asto18089@126.com>

---------

Signed-off-by: asto <asto18089@126.com>
…71)

* fix: add activation hints to rlm handle_read text

Signed-off-by: asto18089 <asto18089@126.com>

* fix: add the fourth side to the registry cap pin

Signed-off-by: asto18089 <asto18089@126.com>

* test: cover every submission-echo self-start path

Signed-off-by: asto18089 <asto18089@126.com>

* fix(rlm): teach the activation path on the stdout preview and truncation marker

The two same-class residuals registered by the first round of this PR
are now fixed instead of tracked: the handle-route preview line ("N
chars; retrieve via handle_read", emitted on every eval output past the
handle threshold) and the preview_output truncation marker both embed
the shared HANDLE_READ_ACTIVATION_HINT, each with a runtime pin.

Signed-off-by: asto18089 <asto18089@126.com>

* test(registry): pin the registry_sync description wording and sync the side list

The enumeration comment now names all four text sides and both
compile-time pins, and the registry_sync tool description gains the
missing runtime "eight" pin; the mcp-discovery skill markdown remains
the one side reached only by the tripwire message.

Signed-off-by: asto18089 <asto18089@126.com>

* test(forkguard): make the composer-shell accidental-dispatch defense real

The composer shell test pre-advances the model-client call counter so
every provider request blocks, making the documented bounded-timeout
defense true (the first request used to be absorbed by the canned
completion); the goal continuation test documents why its host-status
sync stays even though the constructor already armed the objective.

Signed-off-by: asto18089 <asto18089@126.com>

---------

Signed-off-by: asto18089 <asto18089@126.com>
Chain-segment ("<!-- scoped instructions: ... -->") and
<project_rule source=...> labels carried absolute paths into the
pinned system prompt, so a checkout move or recase changed the prompt
prefix and could emit a spurious <context_update> history append.
Render both labels repo-relative (git-root-relative, forward
slashes), falling back to workspace-relative for rules outside any
checkout and to the absolute spelling only for paths outside the
label root (unreachable by construction).

Pins Pinvou/pinvou-agent#514.

Signed-off-by: asto18089 <asto18089@126.com>
* fix: bind region origin in screenshot rasters

Signed-off-by: asto18089 <asto18089@126.com>

* test: pin screenshot raster-origin binding

Signed-off-by: asto18089 <asto18089@126.com>

* fix(computer-use): aim screenshot regions in global screen points and bind measured pixels

Review of the codewhale-hq#554 first cut surfaced three gaps this closes:

- darwin derived scale from _spdisplays_resolution, which is the POINT
  resolution on Retina panels — every region shot bound scale 1 while the
  PNG carries 2x, so clicks still landed 2x off (verified on real
  hardware). The binding now measures the actual PNG (sips, as zoom
  already does) and the metadata fallback uses the real pixel field
  _spdisplays_pixels.
- darwin treated -R as display-space with -D; the weight of evidence says
  screencapture crops -R in GLOBAL screen points. A region now pins the
  crop itself (no -D) and binds the region verbatim — which also makes
  region shots work on displays whose origin macOS will not report — and
  clamps only against a complete display union, since clamping on a
  partial union could pull another display's region into the main one.
- linux rejected negative regions although grim/scrot/import crop in
  global layout points that go negative on exactly the layouts the
  virtual-screen binding exists for; and it hardcoded scale 1 while grim
  renders at the highest output scale, so virtualScreen now carries the
  contributing scale. An unbound shot no longer advances the backend's
  last raster — the crop rides along as 'path' so the previous binding
  stays whole and a follow-up zoom cannot slip past the foreign-source
  guard with a stale frame.

The server stops guessing: a screenshot with neither points nor origin
keeps the previous binding (or none — coordinate targets then fail closed
with no_raster) instead of binding (0,0), and over ssh the backend's own
note is joined with the scp hint instead of overwritten. The screenshot
schema now describes the global frame and the clip.

Signed-off-by: asto18089 <asto18089@126.com>

* test: pin global region frames, measured pixels, and the unbound-raster contract

Six mutation checks revert each binding fix and fail the suite: the raw
-R passthrough, resolution-only metadata scale, the linux n >= 0 gate,
the linux scale-1 hardcode, the server (0,0) fallback, and the darwin
file-swap under a stale frame. The Retina fixtures use the real
system_profiler field pair (resolution = points, pixels = backing).

Signed-off-by: asto18089 <asto18089@126.com>

* fix(computer-use): aim win32 screenshot regions in global points

Review round on #72 surfaced the last cross-platform coordinate
mismatch: the shared schema, darwin, and linux all define the
screenshot region in GLOBAL screen points — the frame list_displays
and cursor_position report, negative on multi-monitor layouts — but
win32 required non-negative x/y and consumed them as offsets from the
VirtualScreen top-left. On layouts whose VirtualScreen origin is not
(0,0) the same tool arguments cropped a different position, and a
region on the left/upper display (negative global coordinates, e.g.
[-1500,100,400,300] with VirtualScreen x=-1920) was rejected outright.

The win32 backend now consumes global points like the other backends:
the script derives the VirtualScreen-relative crop offset itself
(rx = region.x - bounds.X; node cannot know the bounds before the
script echoes them), clamps it against the virtual screen, and copies
at bounds + offset as before. Node recomputes the same clip with
clampRegion and restores global points, so the bound points remain the
crop actually taken. Tests pin the contract from global-coordinate
inputs — including a negative region aiming at the left display and a
region clamping past the left/top edge — and the malformed-region gate
now covers junk, arity, and degenerate sizes without the negativity
clause.

Signed-off-by: asto18089 <asto18089@126.com>

---------

Signed-off-by: asto18089 <asto18089@126.com>
* refactor: extract checkpoint recognition to leaf module

Signed-off-by: asto18089 <asto18089@126.com>

* docs(tui): point the moved recognition docs at their consumers

The leaf has no consumers of its own, so "every consumer here" was
vacuous; name the compaction/runtime_handoff consumers instead. The
module doc's no-reverse-edge claim gains its production qualifier and
names the disclosed test-module residue.

Signed-off-by: asto18089 <asto18089@126.com>

---------

Signed-off-by: asto18089 <asto18089@126.com>
#77)

The bounded-child regression test in tools/process.rs is #[cfg(unix)]
(it drives a real `sh` and a backgrounded grandchild holding the pipes),
but the module-level `use super::*` and the `shell_command` helper it
feeds on stayed ungated. Under cfg(test) on Windows the test is compiled
out and the leftovers trip `-D warnings` (unused import + dead code),
which is how pinjou-agent#595's windows-rust-test caught it after the
2026-09-22/23 batch landed (#63 introduced the module).

Gate both items with #[cfg(unix)] so the module is empty rather than
broken on Windows; no behavior change on unix targets.

Signed-off-by: asto18089 <asto18089@126.com>
* fix: recognize restore carriers by provenance

Signed-off-by: asto18089 <asto18089@126.com>

* fix: correct restore residual docs and pin the anchor order

Review follow-ups on the restore-by-provenance change:

- The loose fallback fires on the structural absence of a stamped
  carrier, not on session age: it also covers histories that never
  compacted, where checkpoint is None and a pasted header is dropped
  outright. State that in the doc comment instead of equating the
  branch with pre-provenance saves.
- Point the history_recognition doc at the live system-prompt scanners
  (extract_compaction_summary, strip_summary_text);
  is_compaction_summary_text has no callers.
- Add the mirror-order regression test: a pasted full summary BEFORE
  the real carrier is the anchor-theft order, so the test pins the
  authoritative summary landing at the real carrier's index, with
  distinct carrier/checkpoint texts so leaving the old carrier in
  place cannot pass.

Signed-off-by: asto18089 <asto18089@users.noreply.github.com>

---------

Signed-off-by: asto18089 <asto18089@126.com>
Signed-off-by: asto18089 <asto18089@users.noreply.github.com>
Co-authored-by: asto18089 <asto18089@users.noreply.github.com>
* fix(tui): allow the fetch_update rename deprecation

Rust 1.99 renamed Atomic::fetch_update to try_update and deprecated the
old name, which now fails the workspace warnings = "deny" gate whenever
the floating stable toolchain resolves to 1.99 (first seen as
pinjou-agent#505's windows-rust-test regression on 2026-10-01, where the
Windows runner picked up stable 1.99.0 mid-day). The workspace MSRV
(1.88) predates the new name, so keep the old call and silence the
rename deprecation at the only call site until the MSRV passes 1.99.

Verified: RUSTUP_TOOLCHAIN=1.99.0 cargo check --workspace --all-targets
--locked is clean, and the forkguard_ (131) plus forkguard_benchmark_
(6) lib suites pass under 1.99.0.

Signed-off-by: asto18089 <asto18089@126.com>
Signed-off-by: asto <asto18089@126.com>

* fix(tui): clear clippy 1.99 findings on existing code

The floating stable toolchain now resolves to 1.99.0, whose clippy adds
three findings on lines this PR does not otherwise touch: a
single-element for loop over ["args"] in agent_plugin's mcp.json
validation (unrolled; identical checks and error strings) and
needless_borrows_for_generic_args on the two &bounded closure borrows
in workflow_panel (the closure is non-capturing and Copy). The
workspace lint gate runs clippy under the same floating toolchain, so
these block the fetch_update deprecation fix from merging.

Signed-off-by: asto18089 <asto18089@126.com>
Signed-off-by: asto <asto18089@126.com>

---------

Signed-off-by: asto18089 <asto18089@126.com>
Signed-off-by: asto <asto18089@126.com>
* fix(route): honor custom wire on runtime routes

A named [providers.<name>] table with wire = "responses" (or
"anthropic") minted a Chat Completions candidate: RouteRequest had no
wire channel, so candidate.protocol() came from Custom's
backward-compatible static policy and every per-turn client built by
DeepSeekClient::from_candidate bound Chat regardless of the table's
dialect. The ambient spawn-time client (DeepSeekClient::new) and
provider_capability_with_wire honored the override, so hosts that
resolve routes per turn - the codewhale-hq#3384 architecture - silently talked to
{base}/chat/completions while their config said Responses.

RouteRequest gains a wire_override honored only for ProviderKind::Custom
(built-ins keep their descriptor policy); the tui route layer populates
it from the active table's dialect (custom_wire_override_for, the same
source provider_wire_format_for_config reads), and the client-internal
rebind/request/limit resolutions pin the transport's own wire so an
engine-internal model switch cannot downgrade it. The minted candidate
is now wire-true end to end: receipts, preflight, and the per-turn
client all read the same protocol.

Pinvou PR codewhale-hq#625 routes catalog GPT models through such a table and hit
exactly this gap (three review rounds missed it because no test pinned
the wire on the runtime path).

Signed-off-by: asto <asto18089@126.com>

* feat(tui): capture reasoning on custom responses

Encrypted reasoning-item capture was gated on the Codex provider, so a
wire = "responses" Custom table streamed reasoning items that were
never persisted and replayed id-less function_call continuations without
their paired reasoning items - the per-round reasoning-continuity loss
the OpenAI cookbook documents for store:false.

Widen the capture gate to every Responses route that sends
include: ["reasoning.encrypted_content"] (Codex plus Custom tables;
DeepSeek and Concentrate stay excluded with their own contracts). The
replay gate already matches the captured provider tag, so Custom states
replay unchanged; both directions are pinned for the Custom route.

Signed-off-by: asto <asto18089@126.com>

* fix(route): scope the custom wire override to the resolved identity

Review round 1 of #79 (B1, S1): the wire override was read from the
ambient `Config` while `resolve_runtime_route_for_identity*` resolves the
endpoint from the identity-scoped clone, so on every pinned turn path
(per-thread routing, `reload_config`, fleet pins, session restore) the
override could name a different `[providers.<name>]` table than the one
supplying the endpoint: a pinned responses table rode Chat, and an
ambient responses table wired Chat-only relays for `/responses` (a
regression against the static-policy base for multi-table setups). The
override now comes from the scoped clone and is computed only for
`ProviderKind::Custom`.

The per-config `wire` dialect parse moves into the config crate next to
the field it reads (`wire_dialect_override` plus the alias predicates), so
the tui wire-format/capability readers share one alias list with the
resolver instead of byte-identical private copies. The app-server
`/v1/chat/completions` pass-through threads the same override from the
`provider_cfg` that supplies its endpoint, so a `wire = "responses"`
table now fails closed at the handler's ChatCompletions-only guard
instead of silently receiving a chat body.

Also pins the explicit `chat` dialect arm and the `messages` endpoint key
in tests, and drops a copy-pasted `#[allow(clippy::too_many_arguments)]`
on the one-argument helper.

Signed-off-by: asto <asto18089@126.com>

* fix(responses): endpoint-scope reasoning replay; capture zen responses

Review round 1 of #79 (S2, S3): every named Custom table shares the
`custom` provider slug, so Custom-tagged opaque reasoning state replayed
onto any table resolving the same model id — table A's encrypted
reasoning rode table B's wire after a provider switch. The replay gate
now compares an endpoint-scoped tag (`custom/<table>` from the client's
frozen provider identity); no released build mints the old bare tag, so
nothing stops replaying.

Capture also widens to the OpenCode Zen Responses roster, which sends
`include: ["reasoning.encrypted_content"]` and `store: false` but never
captured — the same unpaired-function_call replay loss this PR fixed for
Custom tables. Include and capture now cover the same route set by
construction, and the turn-path pin expressions collapse into
`pinned_wire_override` / `reasoning_provider_tag` helpers.

Signed-off-by: asto <asto18089@126.com>

* fix(route): bind the custom wire gate once; pin the override's edges

Round-1 review residue: the resolver's Custom wire channel was gated by
two `provider_kind == Custom` checks forty lines apart - the endpoint-key
remap and the protocol selection - so a future edit to one gate could
silently desync the key from the protocol. The override is now bound once
above both consumers. The new field is also spelled `RequestProtocol`
per the route module's naming convention (same alias; request-side wire
shapes are spelled that way there).

Test pins: the override mints the wire even on the descriptor's loopback
placeholder base URL; a wire-true custom candidate still carries default
capabilities and UnknownOrStale pricing (no first-party fact
resurrection); a Chat override cannot demote a Responses-descriptor
builtin (OpenCode Zen); and Codex-minted opaque reasoning state never
replays onto a Custom route. The protocol-mismatch tail of
`rebound_for_model_protocol` now documents that the pinned override makes
it unreachable for Custom clients.

Signed-off-by: asto <asto18089@126.com>

* fix(tui): pin wire-capture edges; readiness reads the table wire

Round-1 residue pins for the custom wire channel, rebased onto the identity-scoped override work:

- A Chat-wire Custom table must not mint opaque reasoning state even when a Responses-shaped stream reaches handle_responses_stream - the capture gate keys on the transport's wire, not the event shape. Deleting the wire half of the gate fails exactly this pin.
- Missing or empty encrypted_content must not be captured (an empty blob would poison every later turn) while the stream keeps flowing and both reasoning blocks close. Deleting the empty-content filter fails exactly this pin.
- A typo'd wire = "respones" dialect degrades to the legacy Chat default at both the override reader and the runtime candidate, so the silent-degrade contract of the shared dialect parser stays deliberate.
- route_is_valid_for_model now validates the wire the per-turn route would mint: the dialect of the same table provider_config_for resolves, which also supplies the validated base URL. Outcome-identical today (Custom route validation is protocol-independent), but any future protocol-conditional validation would otherwise silently drift from the turn path.

Signed-off-by: asto <asto18089@126.com>

* fix(responses): bind reasoning replay to the capture endpoint

The endpoint-scoped replay key compared the provider tag, api shape, and
model - but the tag only pins the named Custom table's NAME, not the URL
behind it. A table whose base_url is edited between sessions (proxy to
vendor-direct, relay swap) replayed the old endpoint's encrypted blobs to
the new one, producing sticky 400s whose cause was invisible.

OpaqueReasoningState gains a serde-default endpoint fingerprint (the
catalog's base_url_fingerprint of the client's frozen base URL). Capture
mints it; the replay gate requires it to match, while fingerprint-less
states from fixed-endpoint providers and pre-existing sessions keep
replaying. The custom capture/replay/turn tests pin capture binding,
mismatch drop, and legacy replay.

Signed-off-by: asto <asto18089@126.com>

* fix(responses): derive the capture gate from the include predicate

The capture gate enumerated its providers positively (Codex, Zen, Custom
Responses) while the body builder's include gate was a negative list
(not DeepSeek, not Concentrate). They coincided only through the current
provider roster: a future Responses-wire builtin would silently start
sending include: ["reasoning.encrypted_content"] without capturing,
re-creating the unpaired function_call replay loss on multi-turn tool
continuations.

Both gates now derive from one shared predicate
(responses_route_sends_encrypted_reasoning_include), so the lockstep the
PR body claims holds by construction. The transport-wire half stays in
the capture gate; the current route set is unchanged.

Signed-off-by: asto <asto18089@126.com>

* fix(config): warn on unrecognized wire dialects; pin the parse

A typo'd wire value ("respones") degraded silently to the default Chat
Completions policy on every surface, so a user who typo'd the one string
that switches their endpoint's protocol saw an opaque wrong-wire failure
at request time with no diagnostic anywhere. wire_dialect_override now
logs a warning for non-empty unrecognized values; recognized explicit
chat spellings (chat, openai, ...) stay silent, the parse stays total,
and the degrade contract is unchanged.

Also pins the canonical alias sets, the normalization (case, whitespace,
underscore/hyphen), and the degrade behavior with direct unit tests in
the crate that owns them — coverage was previously indirect via the tui.

Signed-off-by: asto <asto18089@126.com>

* fix(tui): thread the table wire into candidate display receipts

The /provider picker resolved rows through the config-free candidate
wrapper, which pins the static Chat policy — so after the runtime fix a
wire = "responses" row bound Responses per turn while the picker still
displayed Chat Completions as its supported protocol. The wrapper now
takes the wire override explicitly; the picker passes the dialect of the
same row-scoped table that supplied its base URL, and the session-state
and model-switch receipts thread the ambient table's dialect the same
way. Callers that never consume candidate.protocol() (unpinned child
admission, the /model receipt) pass None with that reason stated.

Also corrects three inaccurate comments left by earlier rounds: the
two-pass resolver note (the wire override rides both passes), the
readiness claim (it reads the ambient selection, the same table as its
base URL — identity-pinned tables are the route layer's job), and the
app-server claim (this ingress reads the literal [providers.custom]
field, not the named-table map), plus the ProviderConfigToml::wire field
doc, which still described only the built-in dual-wire vendors.

Signed-off-by: asto <asto18089@126.com>

* test: pin the wire plumbs the audit found untested

Three seams could regress silently because nothing bound them:

- config: the runtime receipt's wire_override (deleting it changed no
  receipt a test asserted) - the new test resolves responses/anthropic/
  chat tables through resolve_runtime_options and pins protocol plus
  endpoint key.
- tui: route_is_valid_for_model's dialect threading now has a
  resolution pin for a wire table. The bool interface cannot observe
  protocol directly (validation is protocol-independent); the resolver
  and receipt pins above carry the protocol assertions.
- app-server: the ChatCompletions-only guard is now posted through the
  real router and asserted as a 400 provider_wire_format_unsupported.
  This replaces non_chat_completions_provider_rejected, which built a
  struct and asserted a field against itself without invoking the
  handler.

Signed-off-by: asto <asto18089@126.com>

* test: pin the capture-to-replay seam and the custom Anthropic transport

Two coverage gaps the audit found:

- Capture was tested at the client and replay at the pure builder, so an
  asymmetric edit to the tag or endpoint fingerprint derivation would
  only be caught by luck. The new seam test captures off a real stream
  on the per-turn client, places the state into history the way the turn
  loop commits it, and asserts turn 2's prepared request body leads with
  the paired reasoning item.
- wire = "anthropic" custom tables were pinned only at the candidate
  level, one layer short of the wire. The new transport test drives
  resolve_runtime_route -> from_candidate against a loopback mock and
  asserts the POST path, x-api-key/anthropic-version headers, and the
  verbatim model.

Also builds the custom responses turn-path client from the resolved
route's identity-scoped config instead of the ambient config, mirroring
the production install path - in a two-table setup the ambient table
would freeze the wrong identity onto the pinned endpoint.

Signed-off-by: asto <asto18089@126.com>

* style: cargo fmt

Signed-off-by: asto <asto18089@126.com>

* fix(responses): fail closed on fingerprint-less Custom reasoning replay

Review on this PR flagged the legacy-state arm of the endpoint gate:
state.endpoint == None matched unconditionally, so a Custom-table state
saved before the endpoint field existed kept replaying after the table's
base_url was edited — exactly the cross-endpoint leak the fingerprint
was added to close, and one that bricks the old session against the new
endpoint (the undecryptable blob stays in history and keeps matching).

Custom tags (the legacy root 'custom' and named 'custom/<table>') are
the only tags whose endpoint can move under a stable tag, so they now
fail closed: no proof of origin, no replay. Built-in providers have
fixed catalog URLs, so their legacy states provably came from the only
endpoint the tag ever had and keep replaying. The upgrade cost on
Custom is one turn of reasoning continuity; fresh captures carry the
fingerprint and replay resumes.

Pins: the legacy Custom state assertion is flipped to require a
reasoning-free request body, the legacy-root 'custom' tag gets the same
pin, and a fixed-endpoint (Codex) legacy state pins the carve-out side.

Signed-off-by: asto <asto18089@126.com>

* fix(responses): gate fingerprint-less reasoning replay on the official endpoint

Built-in base URLs are not fixed: config, env (OPENAI_CODEX_BASE_URL /
CODEX_BASE_URL), and CLI flags can re-point any built-in provider while
its capture tag stays put. The legacy arm of the replay gate therefore
treated fingerprint-less built-in states as provably local when they are
nothing of the sort — the exact cross-endpoint replay the endpoint
fingerprint was added to close, flagged in review.

The gate's `state.endpoint == None` arm now additionally requires
provider_base_url_is_official(kind, current_base_url): legacy states
keep replaying on the official route (pre-upgrade sessions keep their
continuity) and fail closed the moment the client points elsewhere.
The client threads its frozen base URL into the body builder; the
comments that carried the false fixed-URL premise are rewritten, and
the migration-cost claim is corrected (a fail-closed state stays in
history and is dropped every turn until compaction, not one turn).

Tests: a re-pointed built-in legacy state now fails closed (the
reviewer-found hole), the official-route replay pin keeps passing, and
the named-table fixtures these files hand-rolled collapse into one
test_support::custom_named_table_config helper.

Signed-off-by: asto <asto18089@126.com>

* fix(tui): gate the prompt suggestion on the resolved route's protocol

route_is_supported_suggestion_provider is a static pre-filter that
cannot see a named table's wire dialect, so a wire = "responses" /
"anthropic" Custom table passed it. Before the runtime-route fix the
turn itself also rode Chat, so the opt-in ghost-text suggestion and the
turn agreed; with wire-true turns it became the one remaining path
still POSTing a hard-coded Chat body to {base}/chat/completions on a
table whose resolved route answers on another protocol.

resolve_credentials_for_identity now fails closed unless the resolved
route candidate speaks Chat Completions — the same identity-scoped
resolution the turn path uses, so the suggestion can never disagree
with the wire the turn ran on. Pinned both ways: a wire=responses table
resolves nothing, the same table on its legacy Chat wire still does.

Signed-off-by: asto <asto18089@126.com>

* test: route the last named-table fixture through the helper; pin the anthropic dialect on the pass-through guard

The readiness wire test now builds its table through
test_support::custom_named_table_config like every other named-table
test, and the app-server fail-closed pin gains the wire = "anthropic"
arm next to the responses one: the Chat-Completions-only guard must
reject both dialects through the real router.

Signed-off-by: asto <asto18089@126.com>

* fix(config): share the anthropic alias list; scope the dialect warning to custom tables

wire_prefers_anthropic (built-in dual-wire base-URL resolution) kept a
byte-identical copy of the anthropic alias list that
provider::wire_dialect_prefers_anthropic now owns; it delegates to the
canonical parse so one alias list genuinely cannot drift. The
unrecognized-dialect warning and the wire field doc are scoped to what
actually happens: every consumer gates the override on
ProviderKind::Custom, so the warning can only fire for a custom table,
while a built-in vendor's dialect space degrades silently.

Signed-off-by: asto <asto18089@126.com>

* fix(config): fingerprint authority-glued query URLs by their host

A base_url like https://gw.example?tenant=1 glues the query onto the
authority, and secret_free_fingerprint_input mapped every such URL to
one shared redacted constant — two distinct hosts shared a single
fingerprint, i.e. one replay scope for captured opaque reasoning state
across genuinely different endpoints. Fingerprint the credential-free
host instead; userinfo and the query/fragment still never enter the
digest. The truly unreachable forms (scheme-less, non-http(s), empty
authority) keep the sentinel, so persisted keys for them stay stable.

Pinned: distinct hosts differ; the same host under different glued
queries or fragments agrees with the ordinary spelling of the endpoint;
userinfo cannot influence the digest.

Signed-off-by: asto <asto18089@126.com>

* test(tui): pin the wire-bound client's internal rebinds

rebound_for_model_protocol and bind_request_to_protocol feed
pinned_wire_override back into the resolver so an engine-internal model
switch cannot downgrade an endpoint-scoped Custom client; the guarantee
was documented but untested. Deleting either plumb now fails exactly
this pin: the rebind rebuilds the client as Chat, the per-request route
bails on the protocol mismatch.

Signed-off-by: asto <asto18089@126.com>

* fix(config): name the table in the unrecognized wire-dialect warning

The warning carried only the raw dialect, so with several named tables
a typo'd wire value was not locatable, and nothing pinned that the
diagnostic fires at all. wire_dialect_override now takes the table
label from every consumer (runtime receipt, tui route layer, preflight,
app-server pass-through) and includes it in the log line; a hand-rolled
capture subscriber pins the emission contract: exactly the unrecognized
non-empty dialect warns. Also scope the config-free resolver wrapper's
comment to its real contract — the fleet hermetic fallback does read
candidate.protocol(), as a display label on an arm that excludes
Custom.

Signed-off-by: asto <asto18089@126.com>

* test(tui): pin the /provider row's wire-table protocol

Round 3 of #79 shipped exactly this class of bug: the picker row
resolved through the config-free wrapper and showed chat for a
wire = "responses" table. The display site had no regression pin of
its own.

Signed-off-by: asto <asto18089@126.com>

* docs(core): note the downgrade direction of the endpoint fingerprint

A state carrying endpoint read by a pre-fingerprint build drops the
unknown field and restores that build's gate for the session; the
provider tag alone still blocks Custom states the old build cannot
produce.

Signed-off-by: asto <asto18089@126.com>

* test(config): share the tracing capture fixture across config tests

The wire-dialect warning pin carried its own inline CapturedEvents
subscriber. Lift it into a cfg(test) module so the forced-custom label
pin can capture events too, instead of growing a second copy.

Signed-off-by: asto <asto18089@126.com>

* fix(config): name the read table in the forced-custom dialect warning

resolve_runtime_options read the dialect from the literal legacy
[providers.custom] table whenever the provider came from CLI/env, but
labeled the warning with named_custom_provider_id() — so a config
selecting relay_a plus --provider custom warned about relay_a's table
while degrading [providers.custom]'s typo. Scope the label by the same
ProviderSource::Config condition that picks the named table. Pinned by
cli_forced_custom_receipt_warns_against_the_table_it_reads; deleting
the condition fails exactly that pin (mutation-checked).

Signed-off-by: asto <asto18089@126.com>

* fix(tui): scope the launch wire override to the launch identity

The launch receipt read the wire override from the ambient config
while the base URL came from effective_auth_config, the clone scoped
to the resolved launch identity. Those diverge when
settings.default_provider re-points the launch at a different custom
table than config.provider selects: the receipt minted the ambient
table's protocol for the launch identity's endpoint. Receipt consumers
are protocol-independent today, so this was latent; the sibling call
sites (apply, session_state) verified identity-aligned and are
unchanged.

Signed-off-by: asto <asto18089@126.com>

* fix(responses): gate reasoning replay on the include predicate

Replay rode state fields alone and was safe only because the body
builder is invoked from the Responses arm. Derive the same
include-sending predicate the capture side uses and require it in the
replay gate, so a route that omits include (DeepSeek's plain
reasoning_text, Concentrate's documented-fields contract) can never
receive an encrypted replay item even if a matching state exists —
include and replay cannot drift, mirroring the round-3 include/capture
lockstep. Pinned by
forkguard_replay_never_rides_a_route_that_omits_the_include; deleting
the predicate half fails exactly that pin.

Also proves the chat capture test's stream flows (one reasoning block
closes) so its empty capture is the wire gate's doing, and adds a
capture-to-replay seam test across a real base_url edit: the state
captured off a live stream at endpoint X is dropped by a client
rebuilt through the production resolve → from_candidate path for the
re-pointed table.

Signed-off-by: asto <asto18089@126.com>

* fix(tui): log the suggestion gate's protocol skip

The round-5 gate returned None silently, so an opt-in suggestion
quietly produced nothing on a responses/anthropic custom table. Name
the provider, identity, and protocol in a debug event — the feature's
established fail-closed style keeps it out of the user's face while
making the skip diagnosable. Also scopes capture_route_authority's doc
to its real contract: the static gate cannot see a named table's wire
dialect, so an authority may be captured for a table whose resolved
route ends up on another protocol and is then never dispatched.

Signed-off-by: asto <asto18089@126.com>

* test(tui): make the readiness wire threading observable

custom_wire_tables_validate_through_the_route_resolver stayed green
with the wire_override plumb deleted, because Custom validation is
protocol-independent — it pinned the outcome, not the threading. Split
readiness_route_request out of the bool probe and pin the request
itself: the override must carry the table's dialect and mint the same
protocol the per-turn authority binds for the same config. Deleting
the threading fails exactly this pin.

Signed-off-by: asto <asto18089@126.com>

* test(tui): lock the route runtime wire tests against live env

Every other new test in this stack takes lock_test_env; the
custom_wire_override_tests module silently depended on no env read
ever being added to the route path. Align it with the convention.

Signed-off-by: asto <asto18089@126.com>

* docs: reference the custom table wire key; correct fingerprint claims

wire is now route-decisive (turn binding, receipts, preflight, the
/provider row, app-server rejection) and had no user-facing
documentation: CONFIGURATION.md's provider-table key list gains the
key with its dialect spaces, degrade-with-warning contract, and the
built-in vendors' narrower silent space; PROVIDERS.md's custom-tables
section points at it.

Also corrects three doc claims the review found inaccurate: the
fingerprint sentinel set (scheme-less inputs take the fall-through
branch and hash their credential-free authority and path, they do not
map to the constant), the OpaqueReasoningState downgrade corner (a
bare-custom-tagged state minted by this build replays on a
pre-fingerprint build without an endpoint check), and from_candidate's
config contract (it must be the candidate's own identity-scoped
config, or captured state is tagged with the wrong table).

Signed-off-by: asto <asto18089@126.com>

* style: drop the stray blank lines before wire_override literals

The round-2 fixture insertions left an inconsistent blank line
between limit_overrides and wire_override in most test literals but
not all of them. Normalize; no behavior change.

Signed-off-by: asto <asto18089@126.com>

* docs(config): scope the fingerprint constructibility claim to the constant

The base_url_fingerprint doc claimed none of its special forms ever
constructs a client, so no captured state could carry their digest — but
the glued-query branch exists precisely because that form does construct
one and can carry captured opaque state. State the asymmetry instead of
the false universal.

Signed-off-by: asto <asto18089@126.com>

* test(tui): finish the named-table fixture consolidation

Route the route_runtime local builder and the hand-rolled literal in the
capture-to-replay seam pin through test_support::custom_named_table_config,
and quote the replay gate's real predicate in the capture test's doc: the
gate compares the client's provider tag, not ApiProvider::Custom.as_str().

Signed-off-by: asto <asto18089@126.com>

* test(config): keep the dialect-warning pin immune to ambient env warns

The receipt test asserted an exact total event count, so a shell exporting
junk CODEWHALE_* values — which EnvRuntimeOverrides::load legitimately
warns about — would fail it despite env_lock. Filter to the dialect event.

Signed-off-by: asto <asto18089@126.com>

* docs(providers): state the anthropic-wire auth contract for custom tables

A wire = "anthropic" custom table authenticates with x-api-key and drops
a conflicting Authorization header; auth_mode = "disabled" suppresses
credential-shaped headers too, so a Bearer-only Messages relay is not
servable through this dialect yet. Say so instead of leaving the gap to
be discovered at request time.

Signed-off-by: asto <asto18089@126.com>

---------

Signed-off-by: asto <asto18089@126.com>
…#88)

* fix(compaction): restore without a checkpoint must not delete history

`restore_compaction_checkpoint` ran its carrier-deletion scan
unconditionally. On a history with no provenance-stamped carrier the
scan falls back to the loose single-block matcher, which reads any user
turn that begins with the summary header as a carrier — and on a
never-compacted session (`checkpoint == None`, the load path at
`core/engine.rs`) that meant such a turn was silently deleted from the
persisted history at every restore, with nothing reinserted in its
place. A summary pasted into the session by the user is indistinguishable
from a pre-provenance carrier by content alone, so the only safe reading
is: with no checkpoint to insert, restore deletes nothing.

Gate the deletion on checkpoint presence. The stamped-carrier path
(replace-and-insert) and legacy loose replacement both keep their
behavior whenever a checkpoint is being restored; a checkpoint-less
restore now only runs topology relocation, which moves stamped carriers
and never deletes. Covered by two forkguard pins: a pasted summary turn
survives a checkpoint-less restore, and so does the saved stamped
carrier.

Found while porting this area upstream: current upstream gates the same
legacy replacement on a saved checkpoint, so this aligns the fork with
the safer reading instead of diverging.

Validation: cargo test -p codewhale-tui --lib restore_ (56 passed,
incl. the two new pins); cargo fmt --all -- --check; clippy delta clean
vs base.

Signed-off-by: asto <asto18089@126.com>

* fix(compaction): calibrate restore comments; pin None-arm relocation

Review round on this PR confirmed the fix and its pins, and surfaced two
wording defects in the comments the fix added. The early-return comment
called the relocated messages "stamped carriers", but restore-time
topology relocation moves Agent-topology sidecars; the compaction stamped
carrier is only their anchor reference. The same comment and the doc
glossed checkpoint == None as "a never-compacted session", while the
fork's design - the stable system prefix never carries the summary - also
lands a compacted session resumed from its session file on that path,
with the genuine saved carrier at stake. Say both precisely.

Also pin the None arm's relocation call: no existing test reached
relocate_restored_compaction_topology through a checkpoint-less restore,
so dropping the call survived the full suite. The new pin fails with the
call dropped (sidecar left trailing the tool result) and passes with it
(sidecar at its anchor, directly after the prompt).

Validation: cargo test -p codewhale-tui --lib restore_ (57 passed, incl.
the new forkguard_restore_without_checkpoint_still_relocates_topology_sidecars);
cargo fmt --all -- --check; clippy delta clean for compaction.rs.

Signed-off-by: asto <asto18089@126.com>

---------

Signed-off-by: asto <asto18089@126.com>
* fix(session): confine archive artifact reads to the session store

`session_artifacts_dir` validated the session id but then trusted the
joined `<sessions>/<id>/artifacts` path. `Path::is_dir` follows links,
so a symlinked session directory or artifacts root redirected the whole
archive walk outside the session store — the member-level symlink skip
cannot see a redirected containing directory, which made the module's
"an export cannot reach outside through a link" claim true for members
only.

Reject the escape loudly: a symlink at either level is an
`InvalidInput` error naming the store, the canonicalized artifacts root
must stay inside the canonicalized store as a backstop for link chains,
and a genuine absence still returns `None` (transcript-only export).
`session_artifacts_dir` returns `io::Result<Option<PathBuf>>` now; the
one production caller (`sessions export`) propagates the error. A
forkguard pin drives both link levels through the real resolver and
asserts the confinement error.

Hardening parity: current upstream confines the same reads to the
trusted session store, so this ports the safer reading instead of
diverging.

Validation: cargo test -p codewhale-tui --lib session_archive (6 passed,
incl. the new pin); cargo fmt --all -- --check; clippy delta clean vs
base.

Signed-off-by: asto <asto18089@126.com>

* fix(session): harden export claims, hard links, and outputs

Review fixes on #89: the module doc claimed an export "cannot read
outside the session directory through a link at any level", but a hard
link planted inside artifacts/ aliases outside content in with no
symlink for the root probe to see - the walker now rejects multi-link
members like upstream (unix nlink / Windows number_of_links). The
writer takes the trusted sessions root and resolves artifacts
internally, so confinement is not opt-in per caller, and it rejects
outputs inside the store the way upstream does: records live at
<store>/<id>.json and a forced in-store output clobbered a live
session.

The resolver hands back the canonical path it validated, invalid ids
are a loud InvalidInput like upstream instead of a silent
transcript-only export, probe errors no longer claim a resolution that
did not happen (and name the detected link), and canonicalize failures
carry context. The export walkthrough states exactly what is enforced
plus the race residual instead of an absolute guarantee.

Validation: the new forkguard pins (hard-link escape, in-store output,
canonical return, traversal rejection) fail on the prior head and pass
here; cargo test -p codewhale-tui --lib session_archive 8 passed;
cargo fmt --all -- --check; clippy delta clean on touched files.

Signed-off-by: asto <asto18089@126.com>

* fix(session): loud probe errors, side-effect-free export rejects

Review fixes on #89 (JensenChen28 round + leftover re-review minors):
the link probes folded every symlink_metadata error into "no link"
and Path::is_dir folded the artifacts stat into false, so an
unsearchable session directory (EACCES on the artifacts probe)
resolved to Ok(None) and a full-fidelity export silently degraded to
transcript-only. Only genuine absence maps to Ok(None) now: NotFound
is absence, every other probe or stat error propagates with the path
it could not inspect (reproduced red on the prior head with a 0o600
session dir).

A rejected in-store output no longer mutates the store either: the
containment check runs against the nearest existing ancestor before
create_dir_all, so a mistyped nested --output leaves no fresh
directories next to the session records; the post-create canonicalize
re-check stays and covers symlinked components created in between.
The residual disclosure covers the output side now too (a swapped
component of the output's parent between the checks and the rename
can still land the archive in the store), the writer doc separates
None from clearing include_artifacts (the latter keeps store checks
and skips id re-validation), the probe error no longer asserts
"artifact root" when the detected link is the session directory, and
cross-session redirection is pinned (an artifacts root linked at
another session's artifacts is rejected like any other link).

Validation: cargo test -p codewhale-tui --lib session_archive 9
passed; the new pins kill their mutants (restored probe-error
swallow, create-dirs-first rejection, outside-only link rejection);
RUST_MIN_STACK=16777216 cargo test -p codewhale-tui --lib: 11925
passed, 5 failed all in the known local-environment families
(remote_control/runtime_threads flock flakes pass in isolation; the
no_new_privs child-process pair fails identically on the pristine
head); cargo fmt -p codewhale-tui -- --check and clippy -D warnings
clean.

Signed-off-by: asto <asto18089@126.com>

---------

Signed-off-by: asto <asto18089@126.com>
* fix: land the timeout-audit review fixes for the merged #63

#63 (d349f25) merged mid-review, so its final review wave never
reached `pinvou3-clean`. This PR carries that wave plus the fixes
from the subsequent review rounds onto the current base, as one
commit; per-area detail lives in the PR description.

- exec: headless runs resolve `request_user_input` via
  `cancel_user_input` instead of parking forever once the engine
  wait became unbounded; the cancellation contract is pinned.
- snapshot: every pre-git window on a wedged mount is bounded
  (workspace canonicalize, the safety classifier's
  re-canonicalization, the first-init size walk, honoring
  `max_workspace_gb = 0`); a killed `git init` heals instead of
  wedging (HEAD-less, half-written, leftover config.lock/HEAD.lock);
  wedge failures surface as errors with stderr evidence and
  stage-keyed remedy hints; drain captures cap at 16 MiB per stream.
- runtime: every approval exit publishes `approval.decided` and is
  race-free by construction (delivery races, monitor-death
  settlement, channel-closed exit); compat streams forward the
  approval posture.
- tasks: liveness heartbeats short-circuit before the manager-wide
  state lock. The human-wait clock pause lives in #78 instead: as
  first landed it changed task-lifecycle behavior and regressed the
  TUI, so it was taken back out.
- vision: the envelope timeout classifies as `ToolError::Timeout`,
  and the inference permit is acquired inside the envelope.
- client: envelope-exceeded requests mark connection health and run
  the recovery probe on every dialect arm, including the Anthropic
  transport arm; the non-streaming budget got an injectable test
  seam.
- app-server: a dropped stream resumes from the last consumed seq
  (bounded reconnects) before falling back to a best-effort
  interrupt, on every turn surface; the `/health` boot probe bounds
  each attempt.
- tools: drain aborts are one helper covering every exit including
  cancel/drop; timeouts SIGKILL the child's process group so forked
  commands no longer outlive the call.
- rlm: the sub-query budget is floored at 1s and the session default
  references `CHILD_TIMEOUT_SECS`.
- mcp: the send-timeout error states the budget instead of tokio's
  `Elapsed` display.
- docs: the 1800s timeout family anchors one complete roster;
  MCP/SUBAGENTS disclose the per-leg budget and wall-clock
  interactions; the RUNTIME_API.md restore contract is corrected.
- tests: every fix above has a regression pin (mutation-verified
  red-green), timing margins are widened, and the Anthropic
  endpoint tests hold the env lock.

Deliberately not fixed here (pre-existing on base unless noted,
follow-up scale): Windows job-object group-kill parity; the
contested inference-permit shape in web_search/voice/
prompt_suggestion; the pandoc.rs old-shape timeout; the two bounded
runners' parallel drain implementations; the engine-side MCP stdio
proxy's unbounded send leg; debounce starvation while heartbeats
stream; the `CODEWHALE_MAX_OUTPUT_TOKENS` test-isolation race.

Verified: `cargo fmt --check`; clippy `-D warnings` on
codewhale-tui, codewhale-app-server, codewhale-core and
codewhale-mcp; `cargo test -p codewhale-tui --lib` 11969/0 and
`-p codewhale-app-server --lib` 103/0 on the final tree; the
load-bearing pins were each mutation-checked to fail red.

No-Issue: review-fix replay for the merged #63; no separate
tracking issue exists. The full findings ledger is the round-6
review comment on #63.

Signed-off-by: asto <asto18089@126.com>

* fix(snapshot): refuse truncated git captures in the restore and history parsers

The 16 MiB drain cap appended a truncation note that no parser
consumed: an over-cap `ls-tree -r` listing would silently shrink the
restore diff's target set, making restore delete files that exist in
the snapshot (the pre-restore safety snapshot makes the loss
recoverable, but the resulting tree is still wrong), and a truncated
`git log` could end in a half-parsed pseudo entry. Both parsers now
refuse a capture that ends with the cap note, and the note itself is
pinned by a real-child drain test.

Signed-off-by: asto <asto18089@126.com>

* fix(tools): treat the drain size cap as truncation evidence on the plugin surface

`drain_truncated` only matched the drain-grace note and only on
stderr, so a plugin whose stdout hit the 16 MiB capture cap came back
as `Ok(success)` carrying cut-off JSON plus a tail note no detector
read. The detector now matches both truncation notes on both streams,
the plugin escalation message names both causes, and a combination
test pins the detector.

Signed-off-by: asto <asto18089@126.com>

* fix(app-server): write the consumed seq back when a turn ends in stream failure

The cursor was only persisted on the `Ok` path, so a thread whose turn
died past the resume budget replayed the failed turn's events from the
store on every retry (turn_id filtering kept it correct; the cost grew
with the history). The error path now persists the last consumed seq,
pinned by extending the exhausted-resume test.

Signed-off-by: asto <asto18089@126.com>

* fix(tasks): lead the init-failure remedy with the stale-lock sweep

The GIT_INIT_FAILED hint told the user to remove the side-repo
directory first, but that state does clear itself: the open-path sweep
clears a lock once it has been stale for an hour (this PR's own
mechanism, pinned by its own tests), and a second host initializing
the same workspace concurrently lands here transiently. Following the
old hint discarded the workspace's whole undo history for a state that
heals within the hour; the hint now leads with waiting and names
removal only as the persistent-failure fallback. The hint test's
negative assertion pinned the wrong premise and now pins the new one.

Signed-off-by: asto <asto18089@126.com>

* fix(runtime): log a dropped forced-approval publish instead of swallowing it

The interrupted/channel-closed exit published `approval.decided` with
`.ok()`, so a failed publish silently stranded the client's pending
UI; it now logs at error severity like the settle path does.

Signed-off-by: asto <asto18089@126.com>

* fix(tasks): skip liveness heartbeats in the trailing drain loop

The heartbeat short-circuit covered the main event loop, but the
post-execution drain called `apply_execution_event` directly, taking
the manager-wide state lock for a no-op against the invariant the
short-circuit states.

Signed-off-by: asto <asto18089@126.com>

* refactor(client): keep the injectable envelope seam private

`non_streaming_request_envelope` was widened to `pub(crate)` for a
caller in the `client::anthropic` child module, which can already see
the parent's private items.

Signed-off-by: asto <asto18089@126.com>

* docs(mcp): disclose the engine-side stdio proxy's unbounded send leg in code

The debt was only stated in the PR description; `Connection::send` now
carries the disclosure where a reader of the code will meet it.

Signed-off-by: asto <asto18089@126.com>

* docs: qualify the budget and approval-contract claims, sync zh_hans

- MCP: the per-leg budget shape holds for stdio servers only; HTTP
  servers are bounded by a single total timeout. Scope the sentence
  and state the HTTP bound (en + zh).
- RUNTIME_API: scope "every resolution is published as
  `approval.decided`" to approvals whose `approval.required` event was
  published and disclose the event-failure exit; the compat stream
  maps an absent `posture` to `null` while the raw stream leaves it
  absent.
- zh_hans/SUBAGENTS: the example `default_max_steps = 120` and the
  missing omitted/zero sentences were stale against the en doc and the
  code (defaults are zero/unbounded); sync them.

Signed-off-by: asto <asto18089@126.com>

* fix(tools): anchor drain truncation evidence to the capture tail

The drain detector matched its notes anywhere in the buffer, while both
writers append their note as the buffer's last bytes and stop retaining
past it. Anchor the match to the tail — the same contract the snapshot
capture's capture_hit_the_cap pins — so a stream that merely quotes a
note mid-body is not truncation evidence, and pin the stricter
semantics with mid-buffer negative cases.

Also close the integration gap the round-7 review flagged: a real
child flooding 17 MiB of unparseable stdout through the plugin surface
must be refused as truncated, not reported as a silent success.

Signed-off-by: asto18089 <asto18089@126.com>

* fix(runtime): log dropped approval resolutions instead of swallowing them

Four approval.decided publishes still ended in .ok(), so a storage
failure silently dropped the event that tells clients to clear their
pending approval UI — the same failure mode the forced-resolution arm
already logs loudly. Log all four (auto-approve, auto-review deny, user
allow, user deny) with the approval id; no control-flow change.

Signed-off-by: asto18089 <asto18089@126.com>

* docs(runtime): align the settle deny comment with live-receiver settlement

The per-entry comment still claimed the send cannot deliver because the
receiver died with the monitor, contradicting the block comment above
it: entries whose receiver is still live are settled too, and the deny
is then pushed through the decision channel (the first settle fixture
pins exactly that).

Signed-off-by: asto18089 <asto18089@126.com>

* fix(tasks): name the cost of removing a half-initialized snapshot repo

The init-failure remedy already led with the self-healing sweep, but the
user-facing hint never said what the removal fallback costs — that
warning lived only in a code comment. State it inline, and pin the
ordering with a test assertion so a regression that puts removal before
the sweep cannot come back green.

Signed-off-by: asto18089 <asto18089@126.com>

* docs(tasks): disclose the debounce flush starvation shape in code

The biased select polls the event arm before the flush arm and the
debounce sleep restarts every iteration, so a stream denser than the
debounce interval defers persistence until the stream pauses or the
loop's trailing flush lands it. Heartbeats never arm dirty, so the
silent-tool tick stream cannot starve it; a dense run of unpersisted
deltas can. Disclose it where the delay lives instead of leaving the
debt only in the commit message.

Signed-off-by: asto18089 <asto18089@126.com>

* docs(subagents): correct the omitted-vs-zero max_steps fallback

"Omitted or zero max_steps remains unbounded even when an operator
default is configured" is half false: only an explicit zero ignores the
operator default (resolve_max_steps keeps 0 as unbounded), while an
omitted budget falls back to it — pinned by
resolve_max_steps(Scout, None, Some(90)) == 90 and contradicted by the
priority list two paragraphs up. State the real rule in English and
sync the Chinese doc that round-7 translated from the wrong sentence.

Signed-off-by: asto18089 <asto18089@126.com>

* docs(runtime): state the required-emit failure outcome without pinning one path

"The tool call is denied" only literally describes the registration
branch; when the auto-approve branch's required emit fails, the tool
call is stopped by the monitor's cancel instead. The externally
observable contract is the same either way — the tool never executes —
so state that.

Signed-off-by: asto18089 <asto18089@126.com>

* docs(mcp): disclose the unbounded stdio send leg in the budget contract

The budget paragraph read as if both stdio legs were bounded, but the
send leg has no deadline — a server that stops draining stdin can park
the write past every budget (disclosed at the write site itself in
round-7). Mirror that caveat in the English and Chinese docs so the
contract matches the code.

Signed-off-by: asto18089 <asto18089@126.com>

* fix(tasks): keep a timed-out gate's exit code absent

The gate moved onto the bounded runner and mapped a timed-out child to
the conventional 128+SIGKILL so the record schema always carried an
integer. That integer reaches hook `exit_code` conditions through
tool metadata, and `reported_tool_exit_code` exists precisely so an
`exit_code` condition never matches a fabricated value (128+SIGKILL
doubles as the OOM-kill code, so OOM hooks fired on every gate
timeout). Base recorded no exit code for a timed-out gate; `timed_out:
true` in the same metadata stays the timeout signal. Pin the absence
with a real timed-out gate, and fix the bool-assert comparison in the
neighbouring spawn-evidence test while there.

Signed-off-by: asto18089 <asto18089@126.com>

* fix(snapshot): sweep stale ref-update locks on open

The recovery work covered config.lock/HEAD.lock at init and index.lock
at open, but the commit path's `update-ref HEAD` (and prune's, and
pack-refs) takes refs/heads/<branch>.lock or packed-refs.lock, and a
git killed mid-update leaves those behind on exactly the wedged mounts
this recovery targets. Nothing ages them out, every later snapshot
fails at the ref update, and the failure is ErrorKind::Other with no
marker, so the once-per-workspace notice gate swallows it: silent
undo-history loss. Sweep both ref-lock shapes on the same staleness
rule as index.lock when the side repo opens; fresh locks stay untouched.

Signed-off-by: asto18089 <asto18089@126.com>

* fix(client): keep isolated anthropic requests off shared health

The new transport-failure marking in the Anthropic arm fires on every
request, including the isolated Auto-classifier one. That clone shares
connection_health through the Arc, so a read-only inspection wrote the
shared health and could fire a real /models recovery probe — the same
contract the generic send paths enforce by dispatching isolated
requests to send_with_isolated_retry. Gate the transport arm on
isolated_request_state and pin the isolation: two stalls from an
isolated client must leave the shared health untouched and fire no
probe.

Signed-off-by: asto18089 <asto18089@126.com>

* fix(runtime): guard the approval rollback and settlement log

Two approval-settlement follow-ups:

The approval.required-emit-failure rollback removed its registration
with a blanket map remove. Providers reuse tool-call ids across
threads, so under a reused id the entry may already belong to another
thread's live waiter and the rollback would strand it — the exact case
the cancel_closed_pending_approval doc forbids. Remove only a
same-thread match (cancel_thread_pending_approval), which retires the
now-unused blanket helper, and pin both sides of the guard.

The monitor-death settlement logged a dropped approval.decided publish
without naming the approval — the one path where several approvals can
be stranded at once, and the only one of the six error logs missing
the id. Add it.

Signed-off-by: asto18089 <asto18089@126.com>

* fix(tools): fall through the group-kill ESRCH arm to the child kill

kill_the_run returned without any kill when kill(-pgid) failed with
ESRCH, on the reasoning that an empty group means the child is dead.
That holds only for a child that stayed in its group: a direct child
can setpgid itself into another same-session group and stay alive, so
the empty group left the un-timed child.wait() running past the budget
with nothing enforced. Fall through to the child-only kill on ESRCH —
a no-op on a genuinely dead child, the last chance to stop an escaped
one.

Signed-off-by: asto18089 <asto18089@126.com>

* fix(tools): bound the echoed stdout in the plugin truncation error

The truncation refusal embedded the whole captured stdout in the
ToolError message, so a 16 MiB capped capture rode the error text
toward the transcript. Echo the first bounded characters instead
(codewhale_hooks::bounded_text also strips control bytes, so a capture
of raw NULs echoes nothing); the diagnostic job is the note plus a
preview.

Signed-off-by: asto18089 <asto18089@126.com>

* docs(snapshot): align the paths module doc with the bounded probe

snapshot_dir_for now runs the workspace canonicalization under the
bounded probe on every call, so the module doc's "cheap to call
repeatedly" promise and the comment's premise that the turn pipeline
already canonicalized the workspace no longer described this code.
State the actual contract: every caller pays one bounded probe, hot
paths should cache, and the raw-path fallback hashes identically for
canonical input.

Signed-off-by: asto18089 <asto18089@126.com>

* docs(mcp): scope the unbounded send leg to the engine-side proxy

The send-leg disclosure landed in the Server Fields paragraph, but
that paragraph documents the TUI pool's per-server settings — and that
pool's send leg is deadline-bounded (the request budget wraps the send
at mcp.rs call_method), so the sentence was not true of the client the
settings govern and contradicted the per-leg sentence above it. The
unbounded blocking write belongs to the engine-side stdio proxy, which
does not consume these knobs at all; drop the false sentence and give
the proxy its own short paragraph with its fixed budgets, in English
and Chinese.

Signed-off-by: asto18089 <asto18089@126.com>

* fix(snapshot): sweep a stale HEAD.lock on the open path

update-ref HEAD takes the HEAD lock and the resolved branch's lock one
after another, so a git killed mid-update can leave a lone HEAD.lock.
A leftover one does not make the side repo unready, so the init-path
sweep never sees it again on an initialized repo, and the open-path
ref-lock sweep only covered packed-refs.lock and refs/heads/**.lock —
every later snapshot then fails at the ref update with the loss
surfacing nowhere, the exact wedge this sweep exists to age out. Add
the HEAD lock to the open sweep and pin both the stale-removed and
fresh-kept rule for it (mutation-checked red against the reverted
sweep).

Signed-off-by: asto <asto18089@126.com>

* fix(client): gate the anthropic status arm off isolated requests

The round-9 isolation gate covered only the transport-failure arm;
check_anthropic_response still marked shared connection health (and
could fire the recovery probe) for HTTP 4xx/5xx envelopes on the
isolated Auto-classifier request, while the generic dialect's isolated
dispatch path writes nothing. Gate the status arm's failure and
success marks the same way, and pin it with a two-500 isolation test
whose expect(0) probe would go red against the reverted gate.

Signed-off-by: asto <asto18089@126.com>

* fix(tasks): name ref locks in the generic git timeout hint

The default arm of the stage-keyed remedy router promised a stale
index.lock for every non-init git timeout, but an interrupted
update-ref/pack-refs leaves a ref lock (HEAD.lock, refs/heads/**.lock,
packed-refs.lock) instead — the sweep covers all of them, so the hint
should not send a debugging user to one file name.

Signed-off-by: asto <asto18089@126.com>

* docs(tasks): correct the heartbeat starvation disclosure

Heartbeats never arm dirty, but their ~200ms cadence still restarts
the 250ms debounce, so dirty state armed just before a silent-tool
phase stays unpersisted for that whole phase — the old note claimed
the tick stream cannot starve the flush at all.

Signed-off-by: asto <asto18089@126.com>

* docs(tools): reword the group-kill error rationale

The child leads its own process group and a group leader cannot leave
it, so the setpgid-escape scenario the ESRCH comment described is not
reachable; the fall-through stays correct as a defensive last resort
on any kill error.

Signed-off-by: asto <asto18089@126.com>

* docs: tighten the posture and proxy timeout wording

RUNTIME_API: posture rides execution-policy denies only (the
full-access auto-approve also forces outcomes but carries auto: true,
not a posture). MCP: the engine-side stdio proxy ignores the timeout
settings, not the whole server config — command/args/env still apply
(en + zh_hans).

Signed-off-by: asto <asto18089@126.com>

---------

Signed-off-by: asto <asto18089@126.com>
Signed-off-by: asto18089 <asto18089@126.com>
…ly (#78)

* Revert "revert(tasks): move the human-wait clock pause to its own PR"

This reverts commit ce5a01f.

Signed-off-by: asto <asto18089@126.com>

* feat(tasks): pause the wall and idle clocks for human waits, answerably

A durable task whose turn hit an approval or user-input prompt was still
bounded by the 30-minute task wall_time, which interrupted the turn at
expiry and denied the pending decision on the human's behalf. The window
is now excluded from the wall clock (like the engine-side turn budget),
the idle clock pauses and restarts fresh when the window closes, and the
exclusion is bounded by a fail-safe cap. Three design holes close the
gaps the first landing shipped with:

- Answerability: the pause applies only where a host can actually
  deliver the decision. The runtime API's task threads are served by
  HTTP decide_approval/submit_user_input and pause; the TUI's private
  task runtime has no such channel, so a prompt there never arms the
  window and the wall clock releases the worker at its own deadline
  (before, it parked the worker for the 24h cap).
- Aggregate bound: the cap now bounds the windows summed across the
  whole task, not only each open window — a task chaining short prompts
  can no longer sum its way past the cap once per prompt.
- Park-poll backoff: while a window is parked the journal poll backs
  off from the 200ms catch-up cadence, doubling up to 2s; an arriving
  decision broadcasts on the subscription and wakes the loop early, so
  the backoff only removes the empty polls.

Pinned by red-green tests: an unanswerable surface keeps the wall clock
running (mutation check fails without the gate), chained short waits
sum to the cap and terminalize between windows (fails without the
hoisted aggregate check), and every wait-writer surface that arms the
window still completes on a late answer.

Signed-off-by: asto <asto18089@126.com>

* fix(tasks): make the parked poll backoff real and the answerability opt-in explicit

The park-poll backoff shipped inert: evaluate() clamped the loop wait to
the 200ms catch-up cadence unconditionally, so the doubling and
PARKED_POLL_CAP never took effect and the PR's own comments described a
behavior that did not exist. The clamp now applies only on the non-parked
path, and the parked cap wait excludes the already-closed windows so the
aggregate budget, not the open window alone, bounds the sleep.

The aggregate HumanWaitTimeout check moved into the pending chain after
shutdown and cancel (it previously jumped the queue and mislabeled a
canceled or shutting-down task as human_wait_timeout), and the window-
closing event arm is now gated like the opening arm, so unanswerable
surfaces no longer emit unpaired HumanWaitEnded signals.

The answerability flag moved from a positional bool parameter to the
named TaskManagerConfig::human_waits_answerable field, defaulting to
false (the TUI's safe default); the runtime API opts in through
server_task_config, which carries the contract in its name and doc.

New deterministic pins cover the backoff cadence and its 2s ceiling, the
aggregate-budget wake, the between-windows terminalization, cancel and
shutdown outranking a spent cap, the idle restart on window close, the
from_runtime default, and the server opt-in.

Signed-off-by: asto <asto18089@126.com>

* test(tasks): pin the human-wait guards at their claimed mechanisms

A fresh from-scratch re-review confirmed the guard behavior but caught
the chained-waits integration test claiming the wrong mechanism: with
back-to-back windows the open-window check fires mid-window (window
one's spend is already in the aggregate total), so deleting the
between-windows aggregate arm leaves the test green. The test is
renamed and recommented to say what it actually pins; the
between-windows arm itself stays pinned by
execution_guard_spent_aggregate_cap_terminalizes_between_windows,
which does go red under that mutation.

Also from the review:
- the backoff pins assert literal durations (800ms, 2s) instead of
  restating the constants, so drifting EVENT_CATCHUP_POLL or
  PARKED_POLL_CAP off their documented 200ms / 2s values fails them;
- the supervised wall-exclusion executor delay grows from 900ms to 2s,
  clearing the thin 500ms scheduling-starvation margin on loaded CI;
- the shared evaluate() comment now names both wake paths (the turn
  loop's event subscription and the supervisor's forwarded task event);
- the human-wait event and config docs state that a custom executor
  emitting the HumanWaitStarted/Ended pair self-declares its waits
  answerable, independent of human_waits_answerable.

Signed-off-by: asto <asto18089@126.com>

* test(tasks): pin the server answerability wiring end to end

The fresh review's MAJOR: the server opt-in had no test below the config
builder, so reverting run_http_server's call site or dropping the flag's
forward into EngineTaskExecutor kept the whole suite green — the one
mutation the PR's own red-green standard did not cover.

run_http_server's task-runtime wiring moves into open_server_task_runtime,
the single wiring point the new pin also drives. The pin reads the flag
where it lands (the executor field the manager runs tasks on) through a
cfg(test) as_engine_executor seam, and goes red if any of the three hops
(builder, forward, call site) regresses to the unanswerable default.

Verified by mutation: inlining the builder in open_server_task_runtime and
hardcoding the forward to false each turn only this pin red (the builder
pin stays green — it never saw those hops); dropping the builder's flag
assignment turns both red.

Signed-off-by: asto <asto18089@126.com>

---------

Signed-off-by: asto <asto18089@126.com>
The Windows has_single_link arm called MetadataExt::number_of_links,
which is still unstable (windows_by_handle) and fails the parent's
Windows stable legs with E0658 - the fork gate never compiled this
crate for Windows, so #89's confinement fix shipped with a
Windows-only compile break. Read the count through the stable
GetFileInformationByHandle on an opened handle instead (the same
mechanism as the Runtime store identity check), with an
open/query failure counting as single-linked: the archive writer
opens the member again for streaming and surfaces any real problem
there. unix/fallback arms only gain an unused path argument to keep
one call site.

Verified: host check/clippy/fmt; scratch-crate check of the new arm
against windows-sys 0.61.2 for x86_64-pc-windows-msvc (ring's build
script blocks a full-crate cross check from macOS).

Signed-off-by: asto <asto18089@126.com>
#75's drain_pipe_caps_the_capture_and_notes_the_truncation reaches
drain_pipe, DRAIN_CAPTURE_CAP and DRAIN_CAP_TRUNCATED_NOTE through
the tests mod's unix-gated 'use super::*' (the #77 pattern), so the
parent's Windows regression leg fails codewhale-tui (lib test) with
7 E0425/E0433 errors: the production items are platform-independent
but the glob that re-exports them into the test scope is not. Import
the four names directly instead - the test is tokio-duplex based and
platform-independent, so gating it away would only surrender Windows
coverage.

Verified: cargo test -p codewhale-tui --lib drain_pipe_caps, fmt,
clippy on the host; the parent's windows-rust-test (regression) leg
is the authoritative Windows compile gate.

Signed-off-by: asto <asto18089@126.com>
Moonshot MFJS validation fails any empty schema node with MissingType,
and sanitize_moonshot_chat_tools answers by silently excluding the whole
tool from the request — the model only sees a tool_search reference it
can never call and keeps emitting the nearest advertised tool instead.
JSON Schema reads an empty items schema as unconstrained items, which is
exactly the MFJS-legal shape with the keyword absent, so the kimi
parameters sanitizer now removes the empty keyword instead of the tool.
Untyped nodes without a semantics-preserving equivalent (empty property
schemas) still fail closed.

Found in the wild: the Pinvou browser MCP advertised
evaluate_script.args = {"type": "array", "items": {}}, so Kimi
requests never carried the tool no matter how often it was activated.

Signed-off-by: asto <asto18089@126.com>
The bounded activation cache (8 cached tools, 16KiB of serialized
schemas) evicts least-recently-used entries when a search admits new
tools, and the evicted tools silently vanished from the model's function
list between turns — a tool that executed successfully earlier in the
same conversation became uncallable with no explanation. The search
result now names the evictions (evicted_tool_references plus a
toolbox_note stating the recovery contract); searches without evictions
keep the legacy result shape unchanged.

Signed-off-by: asto <asto18089@126.com>
@asto18089 asto18089 closed this Oct 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants