# Changelog All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] ### Added ### Fixed ### Changed ## [0.9.0.1] - 2026-11-03 ### Added - **Recent-only view for the conversation sidebar** (`frontend/src/utils/chatTreeRecency.ts` (new: `applyRecencyFilter`, `RECENCY_WINDOWS`, `loadRecencyWindow`), `frontend/src/components/MUIChatHistory.tsx`; tests `frontend/src/utils/__tests__/chatTreeRecency.test.ts`, `frontend/src/components/__tests__/chatHistoryRecencyWiring.test.ts`). Long lists were burying the conversations in use. A clock button in the sidebar toolbar (not shown during a search) opens a menu with *Show all*, *Active in last 24 hours* and *Active in last 7 days*; a caption under the toolbar names the window, counts the hidden conversations and offers *show all*. Recency is the last write (`lastActiveAt`, falling back to `lastAccessedAt` for records without a server stamp). Folders with nothing visible left are hidden unless created or edited inside the window; the current, streaming and running-task conversations always show; a stale conversation that anchors visible forks or TaskPlan folders stays. The choice is persisted browser-wide under `ZIYA_CHAT_RECENCY_WINDOW`. The filter is applied to the cached tree's output rather than inside the tree memo, so the structural hash, sort-only fast path and folder-count cache guard are unchanged. - **Memory tool results in the chat unfold to show the memories touched** (`frontend/src/components/MemoryCard.tsx`, `frontend/src/utils/mcpFormatter.ts` (`MEMORY_TOOL_KIND`, `MEMORY_TOOL_NAMES`, `parseMemoryEntries`), `frontend/src/components/MarkdownRenderer.tsx`, `app/mcp/tools/memory_tools.py`; tests `frontend/src/utils/__tests__/mcpFormatterMemoryRecall.test.ts`, `frontend/src/components/__tests__/MemoryCardFold.test.tsx`, `tests/test_memory_retract_proposal.py`). `memory_search` / `memory_expand` / `memory_context` used to fall through to the generic shape digest (a headline and a `count` fact); `memory_delete` and `memory_retract_proposal` showed only a message line. All seven memory tools now render the memory card. The card has a fold chevron in its header: a recall card opens folded showing the count and the query, and unfolds to one row per memory with id, layer, flags (contested / probationary) and tags; propose / save / delete / retract open with the text visible and fold on click. Retract now echoes the withdrawn text, layer and tags, and delete echoes tags, so the card has something to show. - **Memory-store backup script** (`scripts/backup_memory_store.py` (new)). Snapshots `~/.ziya/memory` into `/.ziya/memory-backups//` (gitignored, survives reboots, unlike `/tmp`), copies the ALE-encrypted store files byte-for-byte so no plaintext is written, and records a sha256 + size manifest per file. `--label`, `--verify ` and `--list` are supported; the last 12 snapshots are kept. - **Sharded pytest suite runner** (`scripts/run_suite_sharded.py` (new)). `start [--shards N]` deals every `tests/**/test_*.py` round-robin by size into N detached `pytest` processes writing `shard_N.log` / `shard_N.xml`; `status` shows per-shard liveness and the last summary line; `report` parses the junit XML and lists failures grouped by file. ### Fixed - **`ziya task` logged every Flex-tier probe as a WARNING and two ERRORs** (`app/utils/custom_bedrock.py` (`custom_invoke`, `_retry_with_extended_context`); `tests/test_custom_bedrock_service_tier_logging.py` (new, 4)). A Task Card run requests the discounted Flex tier optimistically and `BedrockProvider` retries once at the standard tier when a model rejects it, remembering the answer per model for the process. The `CustomBedrockClient` wrapper between them had no notion of that contract, so the expected rejection printed `🔄 INITIAL_ERROR`, `Error in custom invoke` and `Model ID being used` on every first call to each Claude model a card's blocks used (one per `model_tier`), which read as the run failing repeatedly. The wrapper now recognises the tier rejection, notes it at DEBUG and hands it straight back; every other error logs exactly as before. The same wrapper's extended-context retry had a catch-all that rewrote *any* `ValidationException` into "content is too large even for extended context", which on a large first request to a model in a task run (the proactive extended-context path) would have hidden the tier wording from the provider, so the model was never marked and the tier was re-probed on every call; that branch now passes a tier rejection through unchanged. - **Tributary-fold gate oscillated and then wedged a 1M-context conversation over the input cap** (`app/utils/tool_history_rewrite.py` (`FoldPressure.chars_per_token`, `_saved_tokens`, `_payload_ratio`, `fold_pressure_for`, `plan_folds`), `app/utils/handoff_prelude.py` (`record_context_pressure(chars=)`, `record_context_overflow`, `messages_text_chars`), `app/streaming_tool_executor.py`, `app/server.py`; `tests/test_fold_gate_units_and_overflow.py` (new, 12)). Reported by mbcohn: Bedrock rejected every send at 1,003,527 tokens while the gate read ~460k; usage.db showed ~970k and ~350k sent on alternating turns. The gate rebuilt the unfolded size as billed tokens plus fold savings converted at the calibrator's *learned* chars/token ratio, which on that install was 13.4 against a real ~2.5, so savings were undercounted 5× going in and the fold target overshot 5× going out. Savings are now converted at the ratio the measured payload was actually billed at (both pressure producers record the payload's text chars with the tokens). Separately, a "prompt is too long" rejection produced no usage event, so the pre-send estimate stayed on record and every retry sent the identical payload; the executor now records the rejection's token count as pressure, so the next send folds. - **Token calibrator learned 13.4 chars/token from image bytes counted as text** (`app/utils/token_calibrator.py` (`payload_text_chars` (new), `PLAUSIBLE_RATIO_BANDS`, `plausible_band`, `_discard_implausible_samples`), `app/streaming_tool_executor.py` (`_record_calibration`); `tests/test_token_calibrator_text_chars.py` (new, 9), fixture `tests/fixtures/mbcohn_token_calibration_2026-11-02.json`). The per-file attribution cancels algebraically, so every sample carries one turn's whole-payload `chars / tokens`; that char count was `len(str(message['content']))`, the Python repr of the block list, which for a pinned screenshot is ~700k "characters" billed at ~1.5k tokens. With a few images in context every turn's ratio came out ~5× high and piled up just under the generic 15 cap (88% of the reporter's opus-5-5 samples). Chars are now counted text-only (image/document blocks are 0), a Claude sample must fall in 1.5–6.0 chars/token, and a persisted file is purged of out-of-band samples and globals on load, so an already-poisoned `~/.ziya/token_calibration.json` heals on the next start. - **Token bar could sit green while the provider rejected the send as too long** (`frontend/src/components/TokenCountDisplay.tsx`; test `components/__tests__/tokenBarZoneUsesServerPressure.test.ts`). The bar's colour zone used only the client estimate; the Hand-off affordance beside it already used the higher of that estimate and the server's record of the last turn's actual input size. The zone now uses the same rule, so a bar cannot read 20% green after a 1,003,527-token rejection. The figure shown is still the estimate; when the server figure is what drove the zone, the bar and total-row tooltips add "the last send used about N% of the context; this estimate is lower than what was actually sent". Nothing about billed usage is displayed. - **A collapsed shell result counted a heredoc body as output and buried the failure line** (`frontend/src/utils/toolDigest.ts`: `splitShellCommand`, `digestShell`; tests in `utils/__tests__/toolDigest.test.ts`). The shell digest dropped only the `$ ` line, so `python3 - <<'EOF' … EOF` previewed its own script as "4 lines of output". The command now runs through its heredoc delimiter (or backslash continuations) and is exposed as `digest.command`; the exception line of a Python traceback, or a `command not found` / `No such file` / `fatal:` / `Error:` line, is surfaced in the existing amber warning strip, while a line that merely mentions an error class is not. Ported from the unlanded Oct 2 row-model design; its orphaned tests (`toolRowDigest.test.ts`, `ToolBatch.test.tsx`, failing to load for a month) are removed — the rest of that design was superseded by the shape digest and the backend-marked `ToolGroup`. - **Opening a conversation hung the tab (RESULT_CODE_HUNG) when it held a large truncated tool result** — the collapsed tool block's digest ran a `$`-anchored truncation-marker regex over the whole payload, which is quadratic on a long single-line JSON blob (160 KB ≈ 12 s; a ~1 MB MCP result never finished). The marker is now stripped from the last 256 bytes only, anchored at the last `[`, so a `[` inside an earlier string value can no longer start the match and discard complete elements. - **Streaming sidebar rows no longer show a stale timestamp** (`frontend/src/components/MUIChatHistory.tsx`, `frontend/src/context/ChatContext.tsx`, `ActiveChatContext.tsx`). The row's trailing time came from `lastAccessedAt`, which is only stamped when a message is appended, so a conversation deep in a tool-heavy turn kept showing the previous turn's end, tens of minutes old, next to its spinner. While streaming the slot now shows time since the last stream activity (`getProcessingActivityAt`, fed by the processing-state stamp, refreshed at most every 30 s during an uninterrupted text stream) and ticks every 30 s, so active turns read "now" and a stalled one visibly ages. - **Task-card steer notes actually ship** (`app/models/task_run.py` (`TaskRun.steer_notes`), `app/storage/task_runs.py` (`enqueue_steer` / `mark_steer_delivered` / `expire_steers`), `app/streaming_tool_executor.py` (`stream_with_tools(feedback_key=…)`), `app/agents/task_executor.py`, `app/api/task_runs.py` (`POST /task-runs/{id}/steer`), `frontend/src/components/TaskCard/SteerComposer.tsx` (new), `TaskRunInspector.tsx`, `TaskCardInlineTile.tsx`, `useTaskRunStream.ts`, `taskRunApi.ts`, `task_run.ts`, `task-card-inline-tile.css`). The 0.8.8.8 entry for this feature described diffs that were never applied: only `app/utils/task_steer.py`, `tests/test_task_steer.py` and the two frontend tests landed, so the endpoint, the storage methods and the composer did not exist and the 10 tests that exercised them had been failing since 2026-09-22. The implementation is re-anchored on the current tree and is otherwise as described there. - **Diff applier: a same-file multi-block diff no longer drops every hunk after the first** (`app/utils/diff_utils/pipeline/pipeline_manager.py` (`clean_duplicate_headers`, now per `diff --git` block; the merged same-file text now reaches `original_diff`, which is what the stages read; the shadowed first copy of `clean_duplicate_headers` removed), fixture `markdown_renderer_fence_lookahead_false_applied`). A model that emits one `diff --git` block per hunk for the same file repeats identical `---`/`+++` lines; the text-wide dedupe stripped them from the later blocks, the splitter had no file to attach those hunks to, and they were silently discarded — or, on another path, the orphan `+++ b/...` line was written into the file as a context line. - **Diff applier: an over-counted `@@` header no longer misleads the apply stages** (`app/utils/diff_utils/parsing/diff_preprocessor.py` (`lower_overcounted_hunk_headers`, new), `pipeline_manager.py`; fixture `json_escape_sequence`; `tests/test_diff_overcounted_headers_and_same_file_blocks.py` (new, 5)). A header declaring more lines than its body made the parser read past the hunk. It is lowered to the body count, but only for a hunk bounded by a following header — the final hunk's surplus can be truncated trailing context, and the fuzzy matcher's placement depends on it (`prompts_manager_log_level_change` lands on the wrong line otherwise). The single-hunk whole-file-replacement heuristic also fired on an 8-line header over a 6-line body, rewrote the file from the body (dropping `return parsed; }`), copied the diff's indentation onto untouched context lines, and reported `changes_written=False` for a file it had changed; it now requires a 5+ line surplus, takes context lines from the file, and records the write. - **Diff applier preserves an added line's trailing whitespace** (`app/utils/diff_utils/application/patch_apply.py`, four sites). `rstrip()` was used where only the newline should come off, so a `+` line ending in a space lost it (and a file line's trailing spaces leaked into the derived line ending). git apply keeps it; so do we. - **Diff fixtures reconciled** (`tests/diff_test_cases/`): `false_already_applied_similar_block`'s `expected.tsx` was built from a different revision than its `original.tsx` (229 lines differed outside the hunk) and is regenerated from the original; `function_collision` becomes `expect_error` — a diff adding a second top-level `def process()` is refused by the Python validator as the signature of a replace-hunk misapplied as an insertion, which is the behaviour `tests/test_diff_language_handlers.py` pins; `additive_replace_bug` passed a relative path the pipeline never resolves; `test_apply_state*` now expect `already_applied` for an already-present diff; `test_malformed_hunk_warning_dedupe` captures `debug` (the parser demoted that line deliberately); `test_diff_pipeline_language_validation` follows 3ab92b0c — rollback is for fuzzy-placed hunks, an exact-anchor apply is kept even if the author's diff breaks syntax (companion test added). Eleven fixtures remain red; all have been red since at least July and are genuine misplacements (`MRE_*`, `multi_chunk_changes`, `multihunk2`, `simple_three_hunk_insert`, `not_matching_context_multipart`, `list_gemini_models_rest_api`, `d3renderer_container_styles`). - **Release/sweep tests follow the sweep task to the deck** (`tests/sweep_task_source.py` (new), `tests/test_release_task.py`, `tests/test_release_announcement_skill.py`). `ziya task sweep` resolves against `.ziya/tasks.yaml` first and the project's deck card second; the linear file task was demoted to `sweep-old` on 2026-10-01 so the orchestrated card could take the name, and 13 tests that read `tasks["sweep"]` directly had errored since. They now resolve the task the way the CLI does, from a module-scoped fixture (the suite's per-test `ZIYA_HOME` sandbox hides the real deck from inline calls), and the four assertions on the linear prompt's `## Step N` numbering are expressed as block order and best-effort grouping for a card. `MRE_incorrect_hunk_offsets` fixture rewritten: its diff wrapped an if/elif chain in `try:` without re-indenting the bodies, so no placement could yield valid Python; hunks 3–4 and their offsets are unchanged. `test_custom_bedrock` pins `custom_bedrock._current_conversation_id`, which the streaming executor leaves set as a module global. - **`parse_output` reports structured provider errors again** (`app/agents/parse_output.py`). The `{"error": ..., "detail": ...}` → `"Error: "` branch sat after an unconditional `return`, so it had been unreachable and the raw JSON reached the user; the check now runs first (`_error_agent_finish`) and the dead tail is gone. - **Context-overflow retry no longer needs a conversation id** (`app/utils/custom_bedrock.py` (`_retry_with_reduced_max_tokens`)). The `max_tokens` reduction was reachable only through an `elif` that required extended-context support *and* a conversation id, so a direct caller's overflow was re-raised unretried. Both overflow paths now share one helper. - **Legacy `backend_system_tests` reconciled with the code** — 26 failures. Deleted tests that asserted on retired internals (`nova_wrapper.BedrockRuntime`, langserve `RunLog`/`RunLogPatch`, bare-text SSE, a `ChatGeneration` stream shape nothing yields, mock agents defined inside the test); updated the rest to the live contracts (`data: {"content": ...}` SSE, `_check_auth_quick` returning `(ok, msg)`, the retuned `FILE_TYPE_MULTIPLIERS`, no 50k-token skip since e8399a1f, `sonnet3.5` → `sonnet4.6`); re-registered the Gemini post-instruction after the test's own registry wipe. `test_aws_auth`, `test_provider_token_accuracy` and `test_model_capabilities` (live credentials; 117 failures in CI) now carry `pytest.mark.integration`, which `pytest.ini` already excludes by default. - **Stale test doubles brought up to date with the code they stub** (`tests/test_git_ops_amend_force.py`, `test_sibling_by_id.py`, `test_sibling_propagation.py`, `test_task_service_tier.py`, `test_until_*.py`, `test_self_improve_judge_evidence.py`, `test_task_launch_root_fallback.py`, `test_resume_launch_seeds_callee_prefix.py`, `test_tool_gating.py`, `test_project_tool_modes.py`, `test_task_tool_floor.py`, `test_task_executor_headless_tool_denial.py`). `execute_task_block` stubs now accept `headless=`; `StreamingToolExecutor.__new__` fixtures set `.provider`; launch tests patch `run_driver.start_run` instead of the removed `task_cards.asyncio.create_task`; the consent test uses `SCOPE_PROJECT`, which replaced `SCOPE_SESSION` in 8f209cec. - **A model pinned to a conversation is sent from a region that serves it** (`app/streaming_tool_executor.py`). The session region is resolved for the server-wide model, so a conversation pinned to a different model could be sent from a region without a profile for it and rejected (seen when the eu failover had written `eu-west-1`). The executor now clamps the region with `region_for_model` for that request before the model id, client and provider are built; a model with a profile for the session's geography keeps the session region. - **"Never mind" stops a running turn, and tool execution uses the same stop rule** (`app/utils/feedback_directives.py` (`is_stop_directive`), `app/tool_execution.py`). The negation scan ran before the whole-phrase match, so the "never" in "never mind" vetoed its own stop phrase; the whole-phrase check now runs first, and a negation such as "don't stop" or any message with extra content is still passed to the model as feedback. Mid-tool feedback was checked with a separate substring test that treated any message containing *stop*, *halt*, *abort*, *cancel* or *quit* as a stop; it now uses the shared `is_stop_feedback`. - **Concurrent diagram renders no longer produce empty or degenerate SVGs** (`app/services/diagram_renderer.py`). Every `render_diagram` call shares one Chromium, and the existing lock only covered browser creation, so concurrent calls laid out pages in parallel and measurement-sensitive renderers (mermaid's `getBBox` pass especially) intermittently emitted an empty SVG for input that rendered fine alone. Each render now holds a render lock from opening the page until it is closed, matching the frontend's `MermaidRenderQueue`. ### Changed - **Data-retention gate errors include what to do about them** (`app/providers/bedrock.py`). When a Covered Model call is rejected for data retention and the automatic restore fails, the error now ends with a remediation hint built from the restore's denied IAM action and the diagnosis (`retention_diagnostics.remediation_hint`). When neither names an action the error is unchanged. - **Developer scripts, design notes and test coverage** — no runtime behaviour change. Scripts: `scripts/mem_probe/baseline.mjs` (attributes a tab's idle memory floor by removing iframes and off-screen messages step by step), `scripts/complandscape_hitlist.py` (stack-ranked build list projected from the competitive-landscape corpus with a printed formula), `scripts/gen_theme_v2.py` (theme-music generation script). Design: `design/asset-substrate.md`, `design/data-reuse-endemic.md`, `design/data-reuse-spec.md`, `design/kuiper-telemetry-connector-guidance.md`; `Docs/FeatureInventory.md` lists the recent-only sidebar view. Code comments: why the Python handler's duplicate-function check is not narrowed to identical copies (`app/utils/diff_utils/language_handlers/python.py`), and `get_schedule_state` always carrying `registered` / `enabled` (`app/api/task_cards.py`). New frontend tests: inline-math ReDoS guard, bench sync / migration / tool-feedback hooks, chat-history row selection wiring, app lazy-chunk boundary, usage/conversation API client. `templates/index.html` and `templates/asset-manifest.json` are rebuilt from the current frontend. ## [0.9.0.0] - 2026-11-01 ### Added - Shell-config plugins can extend the credential paths the shell refuses to upload with `curl @file` (`app/plugins/interfaces.py` `ShellConfigProvider.get_additional_sensitive_paths`, `app/plugins/__init__.py` `get_shell_config_additions`, `app/mcp_servers/shell_server.py`). The built-in set keeps only generic paths (`~/.aws/`, `~/.ssh/`, key files, ...); deployment-specific ones, such as an SSO cookie directory, come from the plugin. - Plugins can add their own sections to `ziya --info` (`app/plugins/interfaces.py` `InfoProvider`/`InfoSection`, `app/plugins/__init__.py` `register_info_provider`/`get_info_sections`, `app/main.py` `print_info`). A failing provider shows as an `Error` row instead of breaking `--info`. The check for a specific registry CLI on PATH was removed from the core; deployments that need it supply it through this hook. Documented in `Docs/Enterprise.md`. - When a Covered Model call is rejected for data retention and Ziya's automatic restore is refused by IAM, the error now ends with a **What to do** line for users on their own AWS account. It names the IAM role or user, the regions below the required mode, the policy statement to grant (`bedrock:GetAccountDataRetention`, `bedrock:PutAccountDataRetention`), the one-time per-region setting an administrator can apply instead, and what `aws_review` means for the data. A denial by an organization service control policy is reported as "this model cannot be enabled in this account" instead. AWS denial text in the restore error is kept to 400 characters so the reason is no longer cut off (`app/utils/retention_diagnostics.py` (`remediation_hint`, `describe_principal`), `app/utils/aws_utils.py`, `app/providers/bedrock.py`; test `tests/test_retention_remediation_hint.py` (new); `Docs/EnvironmentVariables.md`). - Files shown in chat (`show_document`) have a **Download file** button and, when only a section or line range was shown, a **Show whole file** toggle that expands the block in place. Both read the file as it is on disk now through the existing confined file endpoint (`GET /api/v1/drag/file`); if the file changed since it was shown (SHA-256 differs) the block and the download say so. If the file can no longer be read, Download saves the text that was shown, named as a range (`report.lines-100-200.md`) so it is not mistaken for the whole file. Inline expansion is capped at 2 MB (`frontend/src/utils/documentDownload.ts` (new), `frontend/src/components/MarkdownRenderer.tsx`; test `components/__tests__/showDocumentDownload.test.tsx` (new)). - CLI-style input history in the web composer. Up in an empty composer recalls the last message sent in the current conversation. Further Up/Down presses step through earlier and later messages, and Down past the newest clears the composer. In a recalled multi-line message the arrows move between lines and only cycle from the first or last line. Editing a recalled message ends browsing. History is taken from the conversation's own messages, so each conversation has its own. Left in an empty composer jumps to the first message of the conversation; Right in an empty composer, or with the caret at the end of the input, jumps to the latest and resumes following a streaming response. Both respect the bottom-up display order (`frontend/src/utils/composerHistory.ts` (new), `frontend/src/utils/conversationJump.ts` (new), `frontend/src/components/SendChatContainer.tsx`, `frontend/src/components/App.tsx`; tests `utils/__tests__/composerHistory.test.ts`, `utils/__tests__/conversationJump.test.ts` (new)). - Optional extras now survive upgrades. One registry (`app/utils/extras_registry.py`, new) declares the five extras (`--browser`, `--latex`, `--data`, `--stats`, `--drag`) and drives the installer's tables, the startup banner (which now names every missing extra, not two of them), a new `GET /api/extras` / `POST /api/extras/{key}/install` endpoint, and an "Optional extras" card on the System Information page with an Install button for the extras the server can add itself (browser, data, stats, drag) and the command to paste for the one that needs sudo (TeX on macOS). `~/.ziya/extras_state.json` records which extras the user installed (an extra found already present is adopted as opted in); at startup any opted-in extra that has gone missing, is not platform-blocked and needs no sudo is re-installed in the background with a log line, so a Playwright bump that moves the Chromium build no longer costs `render_diagram` until someone re-runs the installer. Never-installed extras are only announced. `ZIYA_EXTRAS_AUTO_REFRESH=0` turns the refresh off. The browser status text now names the build the installed Playwright pins and any other build found in the cache ("Chromium build chromium_headless_shell-1208 for Playwright 1.58.0 (found chromium_headless_shell-1194, built for a different Playwright)") (`app/utils/optional_features.py`, `app/utils/install_extras.py`, `app/api/extras.py` new, `app/server.py`, `frontend/src/components/SystemInfo.tsx`; tests `tests/test_extras_registry.py`, new). - Music notation for ensemble and harp scores, found by re-typesetting a two-harp score (Pratt, "Round", Harp 1 mm. 19-52) from a photograph (`frontend/src/utils/d3Plugins/musicPlugin.ts`, `app/data/built_in_skills.py` music skill; tests `musicEnsembleScoreGaps.test.ts` (new, 24) with the score as a fixture). `crossStaffLines` draws the "l.h." hand-crossing line from a note on one staff to a note on the other, labelled by the lower note. A measure-level `section` boxes a rehearsal mark mid-score (the spec-level one could only mark the opening bar). Brackets take `hooks: "both"` for the solid, two-hooked phrase/entry bracket, overlapping brackets stack onto separate bands, and every bracket rises (or, below the staff, drops) to clear the notes it spans; an 8va over a ledger-line D6 used to overprint the note. A rehearsal mark sits above a bracket on its bar, and a tempo mark is lifted (the first system moved down to match) when a first-system bracket rises past its band. Layout: explicit `systemBreak`s now define the lines, so the default width budget no longer breaks one bar early and orphans a bar; a last system at least half full is justified to the margin like the others (`justifyLastSystem` overrides); an empty measure (`notes: []`) is documented as an empty bar. Also: the D-138 flat-melody barring is now the pure `segmentFlatStaffIntoMeasures` / `measureCapacityWholeNotes` / `noteWholeNoteUnits`, which `musicFlatNotesWrapG33.test.ts` had pinned since 2186d44f without the code ever being committed. - On the Anthropic Messages path (direct, or Bedrock via mantle) with Claude 4.5+, within-reach MCP servers use the API's own deferred tool loading instead of Ziya's loader (`design/mcp-on-demand.md`, "Anthropic's native path"; `app/utils/mcp_ondemand.py` `native_search_for` / `native_tool_payload`, `app/providers/base.py` `PassbackBlock`, `app/providers/anthropic_direct.py`, `app/streaming_tool_executor.py`, `app/utils/precision_prompt_system.py`; tests `tests/test_mcp_native_tool_search.py`, new). Their tool definitions are sent on every request with `defer_loading: true` and a `tool_search_tool_regex_20251119` entry, so the `tools` array is identical for the whole conversation: a within-reach server costs no prompt-cache miss, on search or on use, where the rebuild path cost one full-prefix write per load. The provider echoes the API's `server_tool_use` and `tool_search_tool_result` blocks back at their original index alongside signed thinking blocks; an unsigned thinking block now drops thinking only, since omitting those blocks would discard the found tools. The within-reach catalog names `tool_search_tool_regex` on this path and Ziya's `search_server_tools` / `load_server_tools` are left out of the tool list. Other providers keep the rebuild. Not yet: the Invoke-API Bedrock provider. - MCP servers can be placed **Within reach** on the bench (capabilities-hub slice 9; `design/mcp-on-demand.md`). A within-reach server stays connected, but its tools are left out of the request until the model calls the new `load_server_tools(name)` builtin; its real tools, with their own schemas, signing, permissions and consent, are then callable from the model's next step in the same turn and stay loaded for the rest of the conversation. The system prompt lists within-reach servers by name, description and tool count beside the skill catalog. Loaded is derived, not stored: a `[MCP server loaded: ]` marker in the conversation's history, or an in-process per-conversation set, intersected with the current placement, so shelving a loaded server withholds it again at once and a fork inherits what was loaded. A Task Card whose `scope.tools` names a within-reach server's tool counts that server as loaded for the run. The loader refuses shelved, on-hand, unknown, disconnected and quarantined servers. A load costs one prompt-cache miss, because the tool list heads the cached prefix. Loading marks the card ✦, and the resolver's `mcp_ondemand_unsupported` 409 is gone. Turning skill discovery off also removes the loader, so within-reach servers become unreachable, and the toggle's warning now says so (`app/utils/mcp_ondemand.py`, new; `app/mcp/tools/mcp_loader_tools.py`, new; `app/utils/bench_prompt.py`, `app/utils/capability_events.py`, `app/utils/precision_prompt_system.py`, `app/services/activation.py`, `app/services/bench.py` `mcp_effective`, `app/streaming_tool_executor.py`, `app/mcp/builtin_tools.py`, `app/api/bench.py`; frontend `utils/activation.ts`, `components/Bench/{laneDnd.ts,BenchBoard.tsx,CapabilityCard.tsx}`; tests `tests/test_mcp_ondemand.py`, new, plus `test_bench_prompt_seam.py`, `test_bench_api.py`, `tests/fixtures/activation_cases.json` and the Bench jest suites). - `scripts/memory_audit_snapshot.py`, `scripts/memory_restatement.py`, `scripts/memory_restatement_calibrate.py` — read-only instruments for judging memory-policy changes empirically. The audit appends a dated row (layer mix, promotion reasons, corroboration attribution rate, live-use counts, duplicate-event count) to `.ziya/outputs/memory_audit.jsonl` so any change reads as a before/after on the same fields. The restatement scripts embed the user side of the whole chat corpus incrementally and score each proposal by whether a later, different conversation restates it, against a size-matched temporal null; the absolute-threshold scoring was shown to be uninformative (a distilled fact never reaches dedup-level cosine with raw transcript) and remains only as the embedding cache. - Managed jobs (`job_start`) are visible outside the turn that started them (`app/api/jobs.py`, new; `app/jobs/index.py`, new; `app/api/task_runs.py` (`status-index` gains `jobs`, `live` counts a running job); `app/jobs/manager.py` (`Job.state()` caches its final state, names its conversation and carries `ended_at`; `kill` waits for a freshly spawned wrapper's pgid); `app/server.py`; frontend `components/TaskCard/{jobStatusGlyph.ts,JobStatusGlyphs.tsx,jobRail.ts,JobChip.tsx,JobExitFeed.tsx}`, `hooks/useConversationJobs.ts`, `utils/jobSeen.ts`, `services/jobsApi.ts` (all new), `TaskRail.tsx`, `Conversation.tsx`, `MUIChatHistory.tsx`, `hooks/useRunStatusIndex.ts`, `services/taskRunApi.ts`, `task-surfacing.css`; tests `tests/test_job_status_index.py` and six frontend suites under `TaskCard/__tests__/job*`, all new; `design/job-runner.md` "UI"). A job lived only in the tool results of its turn, which scroll away during a long run. The sidebar row now shows a terminal glyph per job state (running pulses; exit 0 / non-zero / lost are static, coloured, badged, and clear once the conversation is visited); the open conversation's TaskRail shows a chip per running job with a tail popover, Kill and "Jump to the turn that started it" (resolved from `started_at` against message timestamps, no new state); and an exit card posts once at the thread's bottom when a job the rail was showing finishes. Jobs started during a turn are held back until the turn ends and only shown if still running, so a turn that runs several short jobs does not churn the rail or post a card per command. Jobs owned by a Task Card run are excluded from the sidebar: that run's gear already shows them. Also fixed in passing: killing a job within its first moments left it `lost` with no status, because the wrapper had not yet installed its TERM handler. - A test pinning that proposal corroboration counts distinct foreign conversations, not events (`tests/test_proposals_corroboration_distinct.py`, new). The projection guard in `ProposalsStore._project` already discarded same-origin, repeated and unattributed corroborations — without it, the per-turn re-extraction above let a fact stated once promote as "highly_corroborated" after two turns of the same conversation — but nothing asserted it at the store surface or at the `_evaluate_promotion` seam. - A per-block wall-clock budget, `scope.time_budget_secs` (`app/models/task_card.py`, `app/agents/task_executor.py`; tests `tests/test_task_time_budget.py`, new). At the deadline the model stream is cut off and one closing turn runs with every tool but `emit_artifact` withheld, asking the task to write what it has; the block is then judged normally and carries a `time_budget` record on its artifact and `task_finished` event. A closing turn that overruns its grace (10% of the budget, clamped to 60–300 s) fails the block with signature `time_budget_exceeded`. Merges as the MINIMUM across scope layers (a bound, not a grant), so a budget on a Parallel applies to each lane. Until now the only ceiling on a task was the iteration count: a lane in run 3f831376 spent 2 h 20 min on one query inside that count. The `task_cards` skill documents the field beside `shell_timeout_secs` and in checklist item 5. - A "Data runs: before you press Run" checklist in the `task_cards` skill (`app/data/built_in_skills.py`; test `tests/test_task_cards_skill_data_run_checklist.py`, new), distilled from an 8-hour data card (chat 3f831376) that re-ran one CTE ~60 times in query slices, wrote a 500 MB intermediate no lane read, had six lanes re-pull row-level data because stage 1 persisted aggregates, sized results with an instrument the project had documented as biased, and ran ten slice jobs against one 64-s-capped proxy. The checklist: check the shelf (`memory_search`, `.ziya/`, existing tooling) before authoring; pull once at the finest grain and `dataset_register` it so lanes `analyze` locally; choose the instrument by the question; smoke one slice before fan-out; budget each lane with a stated falsifier; cap concurrency on a shared backend; no intermediate without a consumer; report in the user's unit. The Parallel section now says `repeat_max_concurrency` applies to it — the executor already honoured it (`_execute_parallel`) but the prose never offered it, so the cap was unreachable from an authored card. - Gather contracts can be edited in the deck (`frontend/src/components/TaskCard/GatherContractSection.tsx` (new), mounted on `ParallelBlockEditor.tsx`, `RepeatBlockEditor.tsx`, `UntilBlockEditor.tsx`; `frontend/src/types/task_card.ts` (`GatherContract`, `Block.gather_contract`); test `frontend/src/components/TaskCard/__tests__/gatherContractSection.test.tsx` (new)). A container's "📥 Required outputs" row adds, edits and removes entries: part name, schema, per child / per iteration / in total (only the values the validator accepts for that container), min, max, the `min_units` quorum ("need 2 of 3"), re-run short units, and producer ids. Each entry shows one sentence saying when it fails the container. Fields the backend declares non-optional are omitted when cleared rather than written as null, switching to "in total" drops the quorum and re-run fields that `per: total` refuses, and keys the row does not show (`require_claims`, `container`, an inline schema) survive edits. The card-root Group gets the same row beside its self-improve toggle (per child or in total; no re-run, which the validator refuses on a group). - Gather contracts take a quorum, `min_units` (`app/agents/block_executor.py` (`_check_gather`, `_rerun_targets`, `_gval`), `app/utils/output_contracts.py` (`gather_unit_count`, `gather_requires_every_unit`, `gather_pushdown`), `app/utils/task_card_validation.py` (`_check_gather`), `app/models/task_card.py` (`GatherContract.min_units`), `app/data/built_in_skills.py`; tests in `tests/test_gather_min_units.py` (new)). A Parallel of lanes that may each come up empty had no way to say "at least 3 of these must produce a finding": the default `per: child, min: 1` failed the container on one silent lane, and the suggested `per: total, min: 3` counts parts rather than lanes (one lane emitting three satisfied it) and, on a multi-task Parallel, needs every lane listed as a producer. `min_units: k` on a `per: child` or `per: iteration` gather passes the container when at least k units meet `min..max`; units that fell short are still named in the decisions. Below the unit count the contract is pushed down to producers as optional (validated if emitted, no correction turn, no lane failure), because a lane failing for lacking the part would fail the container the quorum lets pass; `rerun_missing` re-runs short units only when the quorum is unmet. `min_units` at or above the unit count behaves exactly as before. Launch refuses `min_units` below 1, on `per: total`, or above the units that can exist (children, or a counted Repeat's `repeat_count`). - Launch warns about a Task that declares a gathered part `required: false` under a gather that still needs every child or iteration (`app/utils/task_card_validation.py`; tests in `tests/test_gather_min_units.py`). `required` on a Task governs only its own correction turn and failure, never the container's gather check, so the card failed at run time on the first silent lane with nothing said beforehand. The warning names the tasks and suggests a `min_units` value. - Importing a task-card export onto another machine now offers to remap the working directories it carries (`app/storage/task_cards.py` (`portable_cwds`, `import_cards(cwd_map=...)`), `app/api/task_cards.py` (`POST /import/inspect`, `{payload, cwd_map}` wrapper on `POST /import`), `frontend/src/services/taskCardApi.ts` (`inspectImport`), `frontend/src/components/TaskCard/ImportCwdMapModal.tsx` (new), `importCwdMap.ts` (new), `TaskCardsLibrary.tsx`; tests in `tests/test_task_card_export_import.py`, `frontend/src/components/TaskCard/__tests__/importCwdMap.test.ts` (new)). An absolute `scope.cwd` is a path on the exporting machine; the executor confines cwd to the project root, so on the new host the import succeeded but every such block reported `scope.cwd ... escapes the project root` and would have run at the root. The import now lists each distinct absolute path with a directory browser (and "Set all to…" / "Project root" for the 1:1 case) before anything is written, and rewrites the card's and every block's matching cwd; rows left blank import unchanged and still warn as before. - A collapsed folder in the Chats tab now shows what is happening inside it (`frontend/src/utils/chatTreeFolderBadge.ts` (`rollUpFolderStatus`), `frontend/src/components/MUIChatHistory.tsx`; test `frontend/src/utils/__tests__/chatTreeFolderStatusRollup.test.ts` (new)). A conversation that finished while you were away, is still processing, or has a task run in flight was invisible until its folder was opened; the folder row showed only `(76 · 9 folders)`. The row now carries the descendants' status ahead of the count, in the same vocabulary the chat rows use: a processing spinner with its count, a green check with its count, and the summed run-status gears (so a "Waiting on you" or "Held" chip anywhere inside bubbles up). Nested folders and delegate/fork children are included; the open conversation is never counted as unread. Expanded folders show nothing extra, since their children already carry it. - Moving a conversation to another project, or toggling its global flag, now leaves a note in the conversation record (`app/utils/project_change_note.py` (new), `app/storage/chats.py` (`move_to`), `app/api/chats.py` (`move_chat`, `set_chat_global`), `frontend/src/components/ProjectChangeNotification.tsx` (new), `Conversation.tsx`, `api/conversationSyncApi.ts` (`moveChat`), `context/ChatContext.tsx`; tests `tests/test_project_change_note.py` (new), `frontend/src/components/__tests__/ProjectChangeNotification.test.tsx` (new)). The model's working directory changes with the project, but nothing in the history said so, and paths from before the move were read as if they still resolved. The server appends one unmuted `system` message at the change: its text, for the model, names both projects and the previous and new working directories (for a global toggle, the owner's directory and that a turn sent from another project runs in that project's root); its `projectChange` field, for the transcript, renders as a "Moved from project A to project B" row without the directories. A repeated toggle to the same value adds nothing. The move reply carries the note so a conversation followed into its new project shows it at once. - A compact layout for hierarchical tool results (`frontend/src/components/CompactHierarchicalList.tsx`, `frontend/src/utils/hierarchicalGroups.ts`; `layout` carried by `utils/mcpFormatter.ts` and `utils/renderToolPart.ts`; tests `components/__tests__/CompactHierarchicalList.test.tsx`, `utils/__tests__/hierarchicalGroups.test.ts`). A formatter that returns `layout: 'compact'` gets one ~20 px line per hit instead of a padded panel. Rows tagged with the same `group` sit under one header with a hit count, `detail` is right-aligned, `dim` fades a row, and the directory part of a path title is dimmed. Clicking a row expands the same body the default layout shows. Results without `layout` render as before. - **A fork leaves a forward link in its source, at the fork point** (`frontend/src/context/ChatContext.tsx` `forkConversation`; `frontend/src/components/HandoffNotice.tsx`; `frontend/src/utils/types.ts`; tests `frontend/src/context/__tests__/forkForwardNotice.test.ts`, `frontend/src/components/__tests__/HandoffNotice.test.tsx`). Forking copied the history into the new conversation and left nothing behind, so the source gave no sign that a fork had been taken or where. It now gets the same local notice a handoff leaves — system + muted, never sent to the model — appended at the moment of forking with a fork glyph and an "Open “Fork: …”" link; later messages in the source follow it, so the point stays marked. The fork itself does not carry the notice. In the sidebar a fork child now shows the fork glyph in place of the chat bubble, and a handoff continuation shows ⛓ there (`frontend/src/components/MUIChatHistory.tsx`; both had been rendered inside the title, where a long title's ellipsis clipped them). - **Vega-Lite charts labelled UTC are now drawn in UTC** (`frontend/src/plugins/d3/vegaTemporalZone.ts` new; `frontend/src/plugins/d3/vegaLitePlugin.ts` Fix 3.73; test `frontend/src/plugins/d3/__tests__/vegaTemporalZone.test.ts`). Vega-Lite renders a temporal field in the browser's zone unless the scale is `type: "utc"`, and models title the axis "UTC" without setting it: a cell SNR chart with rows in -07:00 and a rule at 08:19:05Z read 01:10 … 01:25 under a "UTC" title. The preprocessor derives the intent from the spec — an axis/channel title naming UTC, GMT, Zulu or "(Z)", a literal Z/UTC in the axis format outside `%` directives, `utcFormat` in a `labelExpr`, a utc `timeUnit`, `scale.type: "utc"` on one layer, or a field whose values all state UTC or are date-only — and, unless a title names a local zone (PDT, CET, "local"), sets `scale.type: "utc"` on every layer sharing that scale, makes local `timeUnit`s utc, rewrites `timeFormat` to `utcFormat` in that axis's `labelExpr`, and formats tooltip/text entries on the same fields in UTC (`formatType: "utcFormat"` with `config.customFormatTypes`, which keeps the author's format string; measured against the real compiler, a utc scale alone leaves tooltips local). Data values are never rewritten; the rewrite is reported like the other preprocess passes. A chart is read against one clock, so the claim is not confined to the view that makes it: a 5-panel vconcat whose bottom axis alone was titled "UTC" first came out with that panel in UTC and the four above it in PDT. A UTC claim in the chart title or subtitle (UTC/GMT/Zulu, or a time written `HH:MMZ`), or else on any scale, is now inherited by every temporal scale without a claim of its own; a view titled with a local zone keeps it, and a chart title naming a local zone (matched case-sensitively, so prose such as "west" does not count) turns inheritance off. - **Parallel tool batches are drawn as one unit** (`app/tool_batch.py` `_run_group`; `frontend/src/utils/toolGroupDisplay.ts` new; `frontend/src/apis/chatApi.ts`; `frontend/src/utils/renderToolPart.ts`; tests `tests/test_tool_batch_group_chrome.py`, `frontend/src/utils/__tests__/toolGroupDisplay.test.ts`). The batch runner overlaps every read-only call issued in one assistant message, but the chat rendered each member as if it were alone, so overlap was invisible and its absence had no tell. For a group of two or more, every `tool_start` now carries `batch_group = {id, size, index}` and one `tool_group_end` follows the results with the group's wall-clock and serial time. The members are drawn inside one bordered container (`frontend/src/components/ToolGroup.tsx`, `frontend/src/utils/toolGroupTokens.ts`; test `frontend/src/components/__tests__/toolGroupRender.test.tsx`): the chat writes a `` comment before the first member to arrive and appends a `` comment carrying the timing, and `renderTokens` collects what lies between into the container. Its header shows a progress bar and `k / N done` while running, then `✓ N calls in parallel · wall · saved X s vs serial (Y s, Z×)` (or "no saving" when the overlap bought under 100 ms); a group in a finished message that never closed reads as stopped. Each member's header keeps the `⫽` mark. A lone call renders exactly as before. A lone HTML comment in a message is no longer printed as text: the group anchor `` used to fall through the renderer's html case and appear verbatim. - **Independent writes now overlap with the reads around them** (`app/utils/tool_footprint.py` new; `app/tool_batch.py` `plan_groups`; test `tests/test_tool_footprint.py`). Every call that was not read-only used to be a barrier, so a message of three reads and two `ledger_record` ran as four serial steps; the audit log after the batching nudge showed multi-call batches saving only 29% of serial time, with `file_write`, `ledger_record` and `emit_artifact` the most frequent members. Each call now has a footprint of the resources it reads and writes, and joins the current group unless it conflicts with a member. `file_write` to a path the write policy already allows, `ledger_record`, `emit_artifact`, `store_output` and `memory_propose` can overlap; two calls touching the same thing (one file, one claim, the artifact list, the proposal store) still run in issue order, so a write followed by a read of it is unchanged. A writer that could open an approval panel (consent mode `ask`, or a path outside the write policy) stays a barrier, as does every other mutating tool. Reads with no declared footprint, including all external MCP tools, never overlap a write. Known gap: a shell read of a ledger or output file in the same message as a write to that store may see either state. - **Reads start while the model is still writing the message** (`app/utils/tool_prestart.py` new; `app/tool_batch.py` `run_tool_batch(prestarted=)`; `app/streaming_tool_executor.py`; test `tests/test_tool_prestart.py`). Tool calls were queued as each `tool_use` block closed and none ran until the whole message had streamed, so a read issued early in a long message waited for every token after it. The leading run of read-only calls in a message now starts the moment its block closes; the batch replays its buffered events instead of running it again, so the chat renders exactly as before and the read's time overlaps generation. The first call that is not a read ends early start for that message, so nothing that writes or could ask for approval runs before the message is complete. A pre-started call does not drain pending user feedback (the stream loop still sees a stop), nothing starts early while feedback is pending, and calls the batch did not take are cancelled on a retried iteration, a mid-stream stop, and after each batch. `ZIYA_TOOL_PRESTART=0` turns it off. - **oMLX as a local runtime** (`app/utils/local_models.py`; tests `tests/test_local_omlx_discovery.py`, new). The Apple-silicon MLX server is now identified (`owned_by: "omlx"` → endpoint `local-omlx`, label *Local · oMLX (:port)*) instead of surfacing as a nameless `Local · :8000`. Its default port was already scanned; a server moved elsewhere is found through `OMLX_PORT` or `server.port` in its `settings.json` (resolved the way oMLX itself does: `OMLX_BASE_PATH`, the macOS app's `~/Library/Application Support/oMLX/base-path`, then `~/.omlx`). Each model is enriched from `GET /v1/models/status` — effective context window (falling back to the checkpoint length, then the listing's vLLM-style `max_model_len`), output cap, vision (`model_type: vlm`), loaded state, and the server's own `reasoning_efforts` ladder, which replaces the DwarfStar default when present. Embedding, reranker, speech, decision and `MarkItDown` entries that oMLX lists on `/v1/models` are dropped from the chat catalog; when the status endpoint is unavailable the filter fails open. `ZIYA_LOCAL_RUNTIME` accepts `omlx`. **Keyed servers no longer vanish:** an oMLX started with `--api-key` answers `GET /v1/models` with 401, which the scan read as "no server here". The scan now retries a refused listing with `ZIYA_LOCAL_API_KEY` (new) and then the oMLX key read from `OMLX_API_KEY` / `auth.api_key` in its `settings.json`, remembers the key that worked on the endpoint, and sends it on status probes and chat requests (`local_endpoint_api_key()` replaces the `"local"` placeholder in `providers/factory.py`, `agents/models.py`, `services/model_resolver.py`). A server that refuses every key is named in a startup warning. - **Visual refresh, stage 1 — "Paper & Ink"** (`frontend/src/styles/theme-tokens.css`, `frontend/src/utils/appearance.ts`, `frontend/src/components/AppearancePopover.tsx`; decision record `design/visual-refresh.md`; tests `utils/__tests__/appearance.test.ts`, `components/__tests__/appearancePopover.test.tsx`). Light mode gets a warm-neutral paper with an explicit surface ladder (canvas / sidebar / surface / inset) mirroring dark mode's; dark mode lifts off AntD's `#141414` onto a slightly cool `#1c1f24` ladder with borders two steps up and the sidebar darker than the reading canvas. Teal is now the one accent (`colorPrimary`, MUI primary, tab underline, selection); magenta stays on the brand mark. AI prose is set in `Charter, 'Iowan Old Style', Georgia, serif`; human turns are a full-width band with a 3px teal rule, AI turns open on the canvas, both sharing one text edge. The header's theme bulb becomes an **Appearance** popover (theme incl. System, light paper tone warm/linen/cool/white, response font, density, prose width cap), stored in `localStorage` as `ZIYA_APPEARANCE` and projected onto `` so the stylesheet selects on them. Bench / Shell / Tasks / Memory gain text labels (icons only under 1100px); ephemeral chat folds into **+ New ▾**; the header middle shows a `project / conversation` breadcrumb. Model, project and shell controls do not move. - **Visual refresh, stages 2–4 — Chats tab, collapse tab, titles** (`frontend/src/components/MUIChatHistory.tsx`, `utils/chatTreeSort.ts`, `utils/relativeTime.ts`, `utils/conversationTitle.ts`, `TaskCard/RunStatusGears.tsx`, `TaskCard/runStatusVocabulary.ts`; tests `utils/__tests__/chatTreeSort.test.ts`, `relativeTime.test.ts`, `conversationTitle.test.ts`, `TaskCard/__tests__/runStatusGearsCompact.test.tsx`). Chat rows are one line in both densities (Comfortable 36px, Compact 26px, from the Appearance popover); the trailing slot has a fixed order — state glyph · beads · relative time — with run-status gears as `⚙N` and the needs-human chips shortened to `Waiting` / `Held` in Compact (full text in the tooltip). The open conversation carries the same teal left rule as a human turn. Folders now sort above conversations at every depth (pinned, then active, then by activity) and root-level chats sit under an **"N unfiled"** divider; a just-touched unfiled chat therefore sits below idle folders, where it used to interleave. The floating panel-collapse button becomes an 18×56 tab on the panel divider, layered above the resizer strip so the whole tab collapses and the strip above and below it still drags. The header is left-justified (brand + `project / conversation` breadcrumb from the left edge) and the message-order toggle moves from a labelled primary button at the left edge to an icon beside Appearance. With the panel collapsed a 28px strip rides above the composer (`components/CollapsedPanelStrip.tsx`, `utils/panelStatus.ts`; tests `utils/__tests__/panelStatus.test.ts`, `components/__tests__/collapsedPanelStrip.test.tsx`) showing the model (with its config button), cache state, the first two active skill pills plus a `+N` overflow, the colour-coded token bar with total and `⇢ hand off`; it reads the same figures the sidebar footer publishes, so the two can never disagree. New conversation titles store the first non-blank line of the first message (capped at 200 chars) instead of a slice sized to the sidebar width at creation, which is why stored titles carried `...` mid-screen; rows and the breadcrumb truncate at render. Existing titles are unchanged. - **Model-driven sidebar organisation** (`app/mcp/tools/conversation_organize_tools.py`, new `conversation_organize` builtin category, on by default; tests `tests/test_conversation_organize_tools.py`). Seven tools let the model file its own work: `folder_list` / `folder_create` / `folder_update` (rename, re-parent with `'root'` for top level, pin) / `folder_delete` (empty folders only), `chat_move`, `chat_rename` and `chat_fork` (same lineage fields as the sidebar's Fork). Metadata only — no tool can touch a message. Moves and renames bump `_version` so a stale browser copy loses on bulk-sync but leave `lastActiveAt` alone, so filing fifty old conversations does not make them all "just used". `ChatGroup` gains first-class `parentId` / `pinned`, and `ChatGroupStorage` now stamps `updatedAt` on create and update (the field `serverFolderWins` compares). The frontend's `tool_display` handler raises `conversationOrganizedFromBackend` for these tools and `ChatContext` answers with an immediate folder + chat-summary pull, so the sidebar updates within the turn rather than on the 30 s poll. - **Chat-history tools show the folder hierarchy.** `chat_list`, `chat_read` and `chat_search` now join `chats/_groups.json` and report `folder: "Parent / Child"` beside the opaque `folderId`; `chat_list` gains `folder_contains`. - **Tributary fold — replay a tool hunt as its reward** (`app/utils/tool_history_rewrite.py` `plan_folds` / `classify_fold` / `rewrite_assistant_text(fold=…)`, `app/models/project.py` `ContextManagementSettings.fold_*`, `scripts/measure_context_composition.py --simulate-fold`; tests `tests/test_tributary_fold.py`, `tests/test_measure_fold_simulation.py`, both new; `design/tributary-fold.md`). When the user asks for one data point and the model greps, dead-ends, narrows and answers, every later turn replays the hunt; muting cannot separate the hunt from the answer because they are one message. An assistant turn with ≥2 tool blocks and prose after the last one now replays, once two further human turns exist, as its pre-hunt prose + a placeholder (call labels, counts, a content-addressed `tb-…` handle) + its final prose — calls, results **and the interim narration** are folded, which no body-level lever reaches. The record is untouched; the placeholder carries no age-relative text so it folds once and is cache-stable thereafter; folds are planned before redundant-body elision so a body inside a folded span is never a stand-in. The model gets the span back with `recall_tributary(handle, mode=full|commands|results, grep=, max_chars=)` (`app/utils/tributary_recall.py`, `app/mcp/tools/tributary_tools.py`, `chat_history` category; tests `tests/test_tributary_recall.py`), which reads the conversation's own record (a muted message still resolves; another conversation's handle is "not found", never an error), and the Session Context block gains a *Folded tool turns* paragraph telling it that its own hunts will be replayed folded, so ids, paths and the query that worked belong in its final text or a store. **On by default** (`contextManagement.fold_tributaries`; kill switch `ZIYA_DISABLE_TRIBUTARY_FOLD=1`). Survey over the full store (464 long chats, 79M replayed tokens): tributaries are 71.7% of replayed history, 16% of each span is interim prose, steady-state reclaim 65% at grace 2, and 28.6% of folded turns are reused verbatim later — grace 1 would double that. - **Drop audio files into local transcription.** Dropping a `.m4a` or `.wav` on the composer, or picking one with the attach button, transcribes it locally with faster-whisper and inserts the text at the cursor, instead of rejecting it as an unsupported file type. `/api/transcribe` now also accepts the `audio/x-m4a`, `audio/m4a`, `audio/wave` and `audio/vnd.wave` types, and falls back to the file extension when the client sends no usable type. Files up to 1 GB (`ZIYA_TRANSCRIBE_MAX_MB`) are accepted, so a 30-minute meeting fits; the transcription routes are exempt from the global 20 MB request cap. Transcription runs as a background job (`/api/transcribe/jobs`) with a progress toast and a Cancel link, and only the conversation it belongs to waits on it. A transcript that finishes after you switch conversations is added to the composer of the one it was started in, where earlier it was dropped. Each file's transcript is inserted as its own block under a one-line header naming the source (`[Transcribed from WAV audio file: take.wav]`), so several dropped files no longer run together. - **Task runs are now in the cost accounting a user can see.** The conversation Info tab breaks `Cost` into the chat's own turns and each Task Card run launched from it, named by card and launch number; a conversation whose only spend was a run no longer reads "no usage recorded". Every run tile gains a **Resources** line that stays with the run (live, finished, historical) and expands to wall clock, model time, turns, tool calls, tokens by dimension, cost with list price and tier mix, tool-side CPU, files written and disk used, then the same per block (`GET /task-runs/{run}/resources`, `GET /api/usage/conversation/{id}`). Run ledger rows now carry the launching conversation's id; older rows are still found through the chat's task bindings. - The 📄 glyph in front of a shown document's path is a drag source: drag it out to Finder, Slack, Docs or an editor to deliver the file itself, while the path text beside it stays selectable (`frontend/src/components/FileDragChip.tsx` `FileDragIcon`, shared `useFileDrag`; test `frontend/src/components/__tests__/showDocumentDragIcon.test.tsx`). ### Fixed - The tool-poisoning scanner no longer drops Pippin and Chorus tools whose descriptions document a marker syntax (`pippin_update_artifact`, `ChorusDocRead`, `ChorusComment` were blocked with `description matches injection pattern '' near: ......`, and a force re-authorization of the server did not prevent it). An HTML comment whose body is at most three `key:value` / `key=value` identifier tokens is treated as a placeholder marker, not hidden text (`app/mcp/tool_guard.py` `_is_placeholder_marker`). Any comment that can carry prose (bare words, a URL, more than three tokens, over 48 characters) still blocks, and the warning text is unchanged. The rug-pull fingerprint quarantine is not affected. Tests: `tests/test_tool_guard_html_comment_markers.py`. - `ziya task` live terminal output. Text after a task's first tool call is now filtered too, so the closing `` tag and `` blocks no longer print raw. Before, `_StdoutSink._break_text()` threw the filter away on every tool call. `` tags are also stripped, with a space put back where the tag sat between two words. A model turn that follows another with no tool call in between (the output-contract correction turn, the time-budget closing turn) now starts on a new line: the executor emits the `\n\n` seam as a `task_text_delta`, where before it was added only to the stored summary (`app/agents/task_executor.py`, `app/cli_card_runner.py`). An `↻ iteration N` marker no longer gets appended to the end of an unfinished text line. Tests: `tests/test_cli_sink_meta_filter.py`, `tests/test_task_turn_seam_delta.py`. - MCP servers installed from the public registries (official-mcp, open-mcp, GitHub) as PyPI packages go into their own venv at `~/.ziya/mcp_services//.venv` and run from there (`app/mcp/registry/installation_helper.py` `install_pypi_package`, `ensure_venv`, `resolve_in_venv`). Before, a bare `pip install` used the first `pip` on PATH; for a pip-installed Ziya that was Ziya's own environment, so a server's dependencies could replace Ziya's. That is how one user ended up with mcp 2.x under a Ziya pinned to 1.x. Open-mcp PyPI installs, which always failed the run-command policy, now work. The run-command policy accepts the service venv's own interpreter (`app/mcp/registry/command_policy.py` `_is_service_venv_python`): it is a symlink out of the install directory, so `/bin/python -m ` was refused; a planted symlink to a non-Python binary is still refused. Tests: `tests/test_registry_pypi_isolation.py`, `tests/test_registry_venv_run_policy.py`. - When an MCP server's executable is missing, the log now names the command and the PATH it was looked up on (`app/mcp/client.py`). - Ziya no longer refuses to start a retention-gated model (e.g. `fable5.1`) when the AWS role is not allowed to read the account data retention setting (`app/utils/aws_utils.py` `_ensure_data_retention`; tests `tests/test_retention_heal_read_denied.py`, `tests/test_retention_mode_ordering.py`). A 401/403 on the `bedrock:GetAccountDataRetention` read at startup or model initialisation now logs a warning and continues without writing, instead of exiting with "DATA RETENTION SETUP FAILED"; the model call decides, and a call actually rejected for retention goes through the existing heal path. Observed with role `bedrock-chatter`, which could not start Ziya although the account was already at `aws_review`. Explicit downgrades/resets and non-permission read failures (5xx) still fail. - `ziya --info` reports whether MCP is enabled even when AST is off (`app/main.py` `print_info`); the line was printed only inside the AST branch. - A drawio block that gives only a URL shows "DrawIO diagrams cannot be loaded from a URL" instead of failing inside the renderer, and the model is no longer told URL references work (`frontend/src/plugins/d3/drawioPlugin.ts`, `app/agents/prompts.py`). Diagrams render from inline XML only. - A message sent while the model was waiting on a background job was held until the job finished. A message sent during a running turn is queued and only delivered between tool calls, and `job_wait` can last up to an hour, which made backgrounding pointless for anything you wanted to ask meanwhile. The wait now gives way: when a message arrives, `job_wait` and `job_start(wait_s=…)` return within about 0.2 s and `shadow_wait` within 10 s, with `reason: "user_message"`, `interrupted_by_user: true` and a `next` call to resume. The job keeps running, and the model receives your message right after (`app/utils/tool_interrupt.py` (new), `app/tool_execution.py`, `app/jobs/manager.py`, `app/mcp/tools/job_tools.py`, `app/mcp/tools/shadow_tools.py`). A stop request still abandons the call as before. Test: `tests/test_tool_wait_yields_to_feedback.py`. - A server whose install directory was deleted while it was running now tells you to restart instead of reporting a throttling error. An upgrade that installs each version into its own venv and deletes old ones can delete the one a running server was started from. The CA certificate bundle in that venv then disappeared, so every model call failed with "SSL validation failed … [Errno 2] No such file or directory". Ziya treated that as a dropped connection: it retried five times and then showed a throttling error. A TLS failure caused by a missing certificate file is no longer retried. It ends the turn with an `installation_changed` error that names the removed directory (or the missing `AWS_CA_BUNDLE` path) and says to restart (`app/utils/install_integrity.py`, `app/providers/bedrock.py`, `app/streaming_tool_executor.py`). The chat shows it as a "Ziya was updated while it was running" banner that explains what happened, which version was removed, and the steps: restart the server, then reload the page. Your conversations are saved, and the banner has no Retry button because a retry cannot succeed until the restart. A missing `AWS_CA_BUNDLE` / `ZIYA_CA_BUNDLE` path gets its own "Certificate bundle not found" variant (`frontend/src/utils/installationChangedBanner.ts`). Tests: `tests/test_install_removed_while_running.py`, `frontend/src/apis/__tests__/chatApiInstallationChanged.test.ts`. - A skill moved to **on hand** in the conversation lens no longer drops back to **shelved** a few turns later. The bench's conversation-layer placements (`placements`) and the conversation's loaded MCP servers (`loadedMcp`) are written to the chat record only by the server. The browser's copy of the chat doesn't hold them, so the next end-of-turn bulk-sync overwrote them with `null`, and a stale copy could restore an old value. Bulk-sync now keeps the stored values for an existing record, the same way it already keeps the handoff fields. A new record, such as a fork, still takes the values it was sent (`app/api/chats.py`). Test: `tests/test_bulk_sync_server_owned_placements.py`. - A chat turn could be lost when its last viewer disconnected during a tool call. The reported case was a `job_start` + `job_wait` whose ssh tunnel and browser died: after the 60 s disconnect grace the server cancelled the turn mid-wait, kept the result in memory for only 5 minutes, and never wrote the assistant's answer to disk, so the reloaded tab showed the unanswered question with Retry. Four changes. (1) Every tool call holds its turn, so no tool wait is cancelled for lack of a viewer (`app/tool_execution._await_tool_result`, `chat_turn_relay.held`). (2) Once a turn has run a tool it is not cancelled for having no viewer at all. The new `ZIYA_CHAT_TURN_UNWATCHED_GRACE_SECS` restores a timeout; tool-less turns keep the 60 s grace. (3) Each turn is checkpointed to `~/.ziya/chat_turns/`: at most every 5 s while running, immediately when a tool starts, and when it ends. Records are encrypted as `conversation_data` and pruned after `ZIYA_CHAT_TURN_RECORD_RETENTION_HOURS` (default 168). The turn status, list and reattach endpoints answer from that record once the turn has left memory; a record a previous server process left mid-turn is reported as interrupted and its replay ends cleanly (`app/agents/chat_turn_store.py`). (4) A reattach no longer sends the stale-stream abort, which since the relay change cancelled the server turn it meant to rejoin (`frontend/src/apis/chatApi.ts`). Tests: `tests/test_tool_exec_relay_hold.py`, `tests/test_chat_turn_persistence.py`. - Reloading the browser no longer cancels a chat turn running in a conversation you weren't viewing, such as one parked in a `job_wait`. After a reload, the reattach watcher only asked the server about the conversation on screen. A turn anywhere else got no subscriber, the relay's 60 s disconnect grace timer ran out, and the turn was cancelled. Now a reloaded tab reads every turn the relay holds once, from the new `GET /api/chat/turns` (`chat_turn_relay.all_statuses()`), and resubscribes to the background ones. The existing duplicate and stale-answer checks in `decideReattach` still apply. Turns in conversations from another project aren't picked up. Tests: `tests/test_chat_turn_relay.py` (`test_all_statuses_*`, `test_reload_resubscribe_from_listing_saves_a_waiting_turn`), `frontend/src/utils/__tests__/chatTurnReattachBackground.test.ts`. - `task_card_write` `edits` can now change a single State variable with a `state_variables.` key. Setting the key to `null` removes that variable. Before this, `scope.` was the only dotted key the tool accepted, so changing one variable meant resending the block's whole `state_variables` dict, including large values that weren't changing (one card had a ~10 KB rules text). Other variables are left exactly as they were, and if any key in the edit list is bad (for example, removing a variable that doesn't exist), nothing is saved (`app/utils/task_card_edits.py`). Tests: `tests/test_task_card_edits.py::TestStateVariableEdits`, `test_tool_edits_one_state_variable_and_persists`. - Four Mermaid rendering problems found while comparing dagre and ELK layouts. (1) Edge labels outside a composite state were drawn white on their light-grey label background under dagre: the contrast pass took the first filled shape anywhere above the label (often the black start dot) as its background. A shape now counts only if it is behind the label (`frontend/src/utils/colorUtils.ts`). (2) In headless renders (the diagram tool, the graphics sweep), a diagram's post-render steps ran before its SVG was attached to the page, so sizing was applied only by a delayed re-run that could land during the screenshot and crop it; ELK renders came out cropped. The plugin now waits for the SVG to be attached first (`mermaidPlugin.ts`). (3) A `--` line inside a composite state, which separates concurrent regions, was deleted, merging the regions; only a `--` outside any composite is removed now (`mermaidEnhancer.ts`). (4) The skip-edge rerouter only recognised Mermaid 10's edge markup and never ran on Mermaid 11/12; it now reads the current markup and runs only for diagrams laid out with dagre, since ELK routes around nodes itself (`mermaidEdgeRerouter.ts`). Tests: `utils/__tests__/colorUtilsLabelBackgroundGeometry.test.ts`, `plugins/d3/__tests__/mermaidStateConcurrentDivider.test.ts`, `mermaidEdgeRerouterModernDom.test.ts` (all new), `mermaidLayoutByType.test.ts`. - When the Bedrock account data-retention switch was lowered mid-session, Ziya's automatic restore failed for roles that may set the switch but not read it. Each call to a Covered Model then failed with "data retention mode 'default' is not available … Automatic restore failed: Failed to read Bedrock data retention mode: HTTP 403 … bedrock:GetAccountDataRetention". The restore read the switch before writing it and stopped when the read was denied. The rejected call already shows the mode is below the requirement, so the restore now writes the required mode (raise-only) even if the read is refused. If the write is also refused, the error lists both denials. Startup and model-initialisation checks still read first, so a more permissive setting is never lowered (`app/utils/aws_utils.py` (`assume_below`), `app/providers/bedrock.py` (`heal_retention_gate`); tests `tests/test_retention_heal_read_denied.py` (new), `tests/test_bedrock_retention_heal.py`). - Mermaid state diagrams drew every state as a doubled box (the state name, a divider, then the name again), and a composite state (`state X { … }`) also got a stray description, which made state diagrams with nested states hard to read. A preprocessing pass (`state-shape-fix`) added an `X: X` line for every state name it found. In Mermaid that line is a state *description*. The pass is removed, and descriptions you write yourself are kept and no longer duplicated (`frontend/src/plugins/d3/mermaidEnhancer.ts`, `mermaidStateLabelText.ts`; test `plugins/d3/__tests__/mermaidStateNoInjectedDescriptions.test.ts` (new)). - After an upgrade, the startup banner listed an extra as missing while the background reinstall was still running (it named `duckdb, pyarrow` mid-install, then `ziya-install-extras --data` reported it already installed). The banner now says "Installing in the background: …" for those. The reinstall notices are now INFO instead of WARNING. A pip extra's notice no longer cites a Playwright version, and the restart note appears only for the browser. Each background install now logs its result (installed, or failed with the last pip lines and the command to run). Python's import caches are cleared after an install, so a package installed while the server runs can be imported without a restart (`app/utils/extras_registry.py`, `app/utils/optional_features.py`; tests `tests/test_extras_registry.py`). - Sending a message could scroll the selected conversation out of view in a long chat list. Sending makes the conversation active and updates its timestamp. The sidebar sort then moves it, and any folder that contains it, to the top of its group, while the list kept its old pixel offset. The list now adjusts its offset so the selected row stays where it was on screen. This applies only to a re-sort of the same conversation that was visible beforehand. Expanding or collapsing a folder, switching conversations and searching scroll as before (`frontend/src/utils/chatListAnchor.ts` (new), `frontend/src/components/MUIChatHistory.tsx`; test `utils/__tests__/chatListAnchor.test.ts` (new)). - Scheduled task cards, end to end. (1) The scheduler had been changed to fire only *registered* cards, but the API that registers one (`app/api/schedules.py`) was never written and no UI or CLI could reach it, so every card with a Schedule block that was not migrated from an old state file never fired. The API now exists (`GET/POST/PATCH/DELETE …/schedules/{card}`, `POST …/schedules/{card}/fire`, `GET /api/v1/scheduler/status`), with a Schedules tab in the Task Cards dialog (one row per schedule: the cadence in words such as "Mondays at 06:00", the next run as a date, the last outcome on one line with the reason expandable; pause/resume, Fire now, and Change schedule / Open card / Unregister in a menu; which process runs the schedules), a Register / Fire now line on the Schedule block and on a card staged in chat, and `ziya schedule list|register|enable|disable|unregister|fire|status` (`app/cli_schedule.py`, new). The block's "enabled" checkbox, which the scheduler ignored, is gone. (2) A scheduled fire was a thin copy of the interactive launch loop: it took no executor lock, so Cancel wrote `cancelled` over a run whose executor kept going and a sibling server's startup marked a live scheduled run `held`; it recorded the executor's deliberate headless unsigned-escalation hold as `failed`; it emitted no `run_started`/`run_completed`; it skipped the credentials preflight and job cleanup. Both paths now use one driver (`app/agents/run_driver.py`, new). (3) The model-facing skill text said staging a Schedule card registers it; it now says the opposite and tells the model to say so. (4) No cadence was validated anywhere: a 4-field cron, `25:00` or an interval of 0 saved and registered fine and the card then sat on "next slot not computed yet" for ever. The card validator refuses them at save/launch, registration refuses them with the reason (422), and a registration that can never fire is flagged in the list, the block line, the CLI and `schedule_state.json` (`cadence_error`). (5) The editor showed `hours`/`09:00` for unset fields while the scheduler assumed `minutes`/`00:00`, so an untouched interval fired every N minutes; the scheduler now uses the editor's defaults and registration records them explicitly. (6) `schedule_timezone` was declared and ignored; it is honoured (IANA name) for `daily_at`, naive `at` and `cron`, falling back to local time with a warning for an unknown name. Docs that called `croniter` optional were wrong; it is a hard dependency (`app/agents/task_scheduler.py`, `app/agents/schedule_registry.py`, `app/api/task_cards.py`, `app/utils/task_card_validation.py`, `app/cli.py`, `app/data/built_in_skills.py`, frontend `components/TaskCard/SchedulesModal.tsx`, `ScheduleRegisterControl.tsx` (new), `ScheduleHistory.tsx`, `TaskCardInlineTile.tsx`, `scheduleCadence.ts`, `services/scheduleApi.ts`; tests `tests/test_schedule_registry.py`, `tests/test_scheduler_run_parity.py` (new, 15), `tests/test_schedule_cli_and_fire_api.py` (new), `scheduleCadence.test.ts`, `schedulesWiring.test.ts`, `scheduleApi.test.ts`). - One large conversation stopped other conversations from reaching the server. Browser-to-server sync (`bulk-sync`) batched 50 conversations per request regardless of size. If a batch went over the server's 20 MB request limit, the whole request was refused (413) and no conversation in it was saved. A new chat in that batch then 404'd when opened, and the next sync rebuilt the same batch. Batches are now limited by serialized size (about 8 MB) as well as count. A refused batch is split and retried, and a refused conversation is reported by its id and size. The sync route itself now accepts up to `ZIYA_BULK_SYNC_MAX_MB` (default 256 MB) so that a single very large conversation also syncs. `RequestSizeMiddleware` gains `path_patterns` (full-match regex), because its prefix rules could not describe `/api/v1/projects/{pid}/chats/bulk-sync`. The save that runs when a tab closes had the same all-or-nothing behaviour against the browser's 64 KB cap for those requests: one large conversation meant nothing was sent. It counted characters rather than bytes, so text with multibyte characters could pass its check and still be over the cap. It now fits conversations into the 64 KB one at a time, measured in bytes, and leaves the rest pending (`frontend/src/api/conversationSyncApi.ts`, `frontend/src/utils/pagehideFlush.ts`, `app/middleware/request_size.py`, `app/server.py`, `app/config/env_registry.py`; tests `api/__tests__/bulkSyncBatching.test.ts` (new), `utils/__tests__/pagehideFlush.test.ts`, `tests/test_request_size_bulk_sync.py` (new)). - Three diff-validation defects, each of which cost a correction round in one session (`app/utils/diff_utils/language_handlers/typescript.py`, `app/utils/diff_validation_hook.py` `reader_reason`, `app/utils/phantom_diff.py`, `frontend/src/utils/diffUtils.ts`, `app/server.py`; tests `tests/test_diff_validation_harness_fixes_1027.py` (new, 15), `diffRejectedMarker.test.ts`). (1) The TypeScript check, which runs with `--noResolve` and is meant to reject only syntax errors (TS1000-TS1999), matched any code beginning with "1", so the semantic TS18004 ("No value exists in scope…") counted as syntax; a diff whose only fault was a type error, or a symbol added by a sibling diff in the same response, was rolled back. (2) That rollback was reported to the model and the reader as "hunks did not match the current file", though every hunk had applied; it now says the patched file failed the language check and quotes the first error. (3) The phantom-diff detector fired on a quoted mention — a reply describing the detector (`the model says "apply the diff above"`) was sent back for a diff it never claimed; quoted and inline-code spans are now excluded. The notice for that case also no longer says "A patch did not apply cleanly" when no patch was refused. - `ziya-install-extras` with no target left out `--data` and `--stats` ("opt-in only"), while the startup banner said "Install everything with: ziya-install-extras"; a user who ran the installer still had no `analyze` tool until they discovered the flags. The default run (and `--all`) now installs every extra that applies to the platform; the flags remain for installing one. On a machine with everything present the run prints one "ok already installed" line instead of a multi-section plan (the plan is still shown for `--dry-run`). Launchers that create a fresh venv per version drop every pip extra on upgrade before the new version's reconcile can see them; reconcile now also reads the previous version's venv (`previous_install_site_packages`) and carries over what it held, so those extras are re-installed rather than announced as never-installed. A background refresh that fails (e.g. an expired index login) is reported on the System Information page (`last_install_failed`, last log lines). Reconcile log lines went through a module logger the project caps at WARNING and never appeared; they use the project logger now (`app/utils/extras_registry.py`, `app/utils/install_extras.py`, `app/scripts/install_extras.sh`, `frontend/src/components/SystemInfo.tsx`; tests `tests/test_extras_registry.py`, `tests/test_install_extras_data.py`, `tests/test_install_extras_idempotent.py`). - A scheduled task-card fire ran with `project_root=None`. A fire has no request and so no `X-Project-Root` header; the scheduler passed `None` to the `ExecutionContext`, never set the request-root contextvar, and recorded `None` in the run's permissions snapshot, so tool calls and artifact paths inside the body resolved against the server process's cwd rather than the project. The fire now resolves its root with the same rule as a header-less interactive launch (`resolve_launch_root`: the project's recorded path when that directory exists, else `None`), sets the contextvar in the run task as the launch endpoint does, and records that root in the snapshot (`app/agents/task_scheduler.py` `_fire_one`; tests `tests/test_task_scheduler.py::test_fire_one_runs_against_project_path`, `::test_fire_one_missing_project_dir_falls_back_to_none`). - The Chromium check accepted any `chromium*` build in Playwright's browser cache, not the one the installed Playwright launches. On a machine with two Ziya installs (for example a pyenv wheel and a per-version launcher venv) on different Playwright versions sharing one cache, a build left by the other install counted as present: `ziya-install-extras` said "already installed" and skipped, Ziya offered `render_diagram`, and the launch failed with "Executable doesn't exist", after which the model suggested a raw `playwright install chromium`. The check now reads the revision the installed Playwright pins from its bundled `driver/package/browsers.json` (no driver started) and requires that build, fully installed (`INSTALLATION_COMPLETE`). Without a readable manifest it falls back to the old any-build rule. The installer uses the same check through the interpreter it runs under, so it now installs the missing build instead of skipping (`app/utils/optional_features.py`; tests `tests/test_chromium_build_match.py`, new, and `tests/test_optional_features_install_path.py`). - When the model revised a task card later in a conversation (`task_card_write`), the card's tile stayed where it was first staged, possibly far up the chat, and nothing showed that it had changed. The tile now moves to the end of the turn that revised it and carries a "revised" tag (×N after repeat revisions, with the time). The place it moved from gets a one-line "revised later — jump to the current version" marker. If the card had only been run in the chat, the run's tile stays where it is and a new staged tile with Run appears at the revision. Edits made in the deck editor still update the tile in place, and revising a card that has no tile in the chat creates none (`app/utils/task_card_revision.py`, new; `app/mcp/tools/task_card_tools.py`; frontend `apis/chatApi.ts`, `types/task_binding.ts`, `utils/taskBindingRevisions.ts`, new, `components/TaskCard/RevisedTileMarker.tsx`, new, `components/Conversation.tsx`, `components/TaskCard/TaskCardInlineTile.tsx`; tests `tests/test_task_card_revision_placement.py`, `utils/__tests__/taskBindingRevisions.test.ts`, both new, and `apis/__tests__/taskCardStageSync.test.ts`). - A diagram taller or wider than the headless capture window lost its top and left edge (`app/services/diagram_renderer.py` `_CAPTURE_FIT_JS`; test `tests/test_diagram_capture_fit_flex_center.py`, new). The harness root centres its child with flexbox, so an oversized child sits at negative coordinates; the fit step removed the overflow clip but not the centring, and an element screenshot cannot reach above y=0. A six-system music score captured with its first two systems and left margin missing and an equal band of empty canvas at the bottom. The unclip now also anchors alignment to the start. - `tests/test_scheduler_heartbeat_survives_system_jobs.py` asserted a spawned system-job pass had started before yielding to the loop. - An `mcp_config.json` whose indentation contains U+00A0 NO-BREAK SPACE (or another Unicode space between JSON tokens) now loads instead of dropping every user server (`app/utils/json_whitespace.py`, new; `app/mcp/manager.py`; tests `tests/test_json_whitespace.py` and `tests/test_mcp_config_unicode_whitespace.py`, new). Text pasted from a browser or chat client often carries NBSP; Python's `json` rejects it with "Expecting property name enclosed in double quotes", which does not point at the cause. The file is now read as `utf-8-sig` (a leading BOM is ignored). On a parse failure, characters between tokens for which `str.isspace()` is true become U+0020 and U+200B / U+FEFF are deleted, leaving string literals untouched, and the parse is retried. A success shows a non-fatal warning finding such as "normalized 26 U+00A0 NO-BREAK SPACE on lines 57-66"; the file is not rewritten. A file that still fails reports the original position, plus a hint when the character there is one of these ("line 57 col 5 is U+00A0 NO-BREAK SPACE, not a space"). - The MCP status modal's Config Stanza tab has a Copy button that writes `JSON.stringify(stanza, null, 2)` to the clipboard, and a manual selection of the stanza is copied as `text/plain` only, with U+00A0 / U+2007 / U+202F mapped to spaces and U+200B / U+FEFF removed (`frontend/src/utils/plainTextCopy.ts`, new; `frontend/src/components/MCPStatusModal.tsx`; test `frontend/src/components/__tests__/mcpConfigStanzaCopy.test.tsx`, new). The stanza was already a plain `
` that bypasses markdown and linkify, so its text never contained NBSP. Rich-paste targets could still pick up the HTML flavour of the selection, which no longer goes on the clipboard.
- The External MCP Servers panels in the MCP status modal show each server's name again, and say where it came from (`frontend/src/components/MCPStatusModal.tsx`, `app/mcp/manager.py` `server_sources` / `get_server_sources()`, `app/routes/mcp_routes.py` `/api/mcp/status` gains `server_sources`; tests `tests/test_mcp_server_sources.py` and `frontend/src/components/__tests__/mcpStatusModalServerName.test.tsx`, new). The header showed the one-line description *instead of* the `mcp_config.json` key whenever a description existed, so a registry-installed server had no visible name. The header now shows the name in bold with the description beside it. The expanded panel gains a Source row: built-in (and whether a config file overrides it), contributed by a plugin, installed from a registry (provider and service id), or declared in a config file (with its path). It also notes when the stanza uses keys Kiro, Q Developer or Claude Code write (`transportType`, `disabled`). Provenance is held beside `server_configs`, not inside it, so it is never written back to the config or shown in the Config Stanza tab. A stanza another tool wrote in Ziya's own format can't be told apart from a hand-written one.
- The phantom-diff correction pass can no longer close with "no diff is needed" while the turn's own tool output still names the error (`app/utils/phantom_diff.py` `find_unresolved_errors`, `app/utils/diff_validation_hook.py` `tool_outputs=`/`phantom_evidence`, `app/server.py`; tests in `tests/test_phantom_diff_reference.py`, +9). Incident 2026-10-25: a turn's `tsc` run reported `error TS18004 ... 'stave'` and its jest run `ReferenceError: stave is not defined`, the response said "once the stave-scope diff above is applied" and ended, the detector fired — and the correction pass grepped for the symbols of *earlier* diffs, found them, and answered that nothing needed re-emitting. Two causes. The pass was blind: `server.py` appended only the feedback as a new user turn, never the assistant turn being corrected, so the sanitizer merged the feedback into the user's previous message and the model had no record of what "the diff above" referred to; the response being corrected is now appended as an `AIMessage` first. And the feedback offered the lazy exit unconditionally: compiler / type-checker / test-runner error lines (`path(l,c): error ...`, `ReferenceError: ...`, `XError: ...`) from this turn's tool results are now collected and quoted in the correction request, which then withdraws "no diff needed" and accepts only the fence, or a quote of the later tool output showing the error gone. Without such evidence the wording is unchanged.
- Bead tools now act on the conversation's real home record when the conversation is driven from a window open on another project (`app/storage/beads.py` `_resolve_chat_storage`; test `tests/test_bead_cross_project_home.py`, new). A global conversation surfaced into another project's sidebar is stored under its own project, but bead storage resolved the record by the window's project and found nothing there, so every bead read and write went to the standalone fallback file: the model saw an empty tree, "closed out" the same stale beads turn after turn, and they came back parked; a later turn from the home window then rewrote the record and deleted the fallback with the other window's writes in it (incident 2026-10-25, conversation 31fa660d). The 2026-10-02 `resolve_chat_home` rule already used by context tools, auto-context and handoff is now applied to beads too.
- The probationary proposal log is now safe under concurrent Ziya processes (`app/storage/proposals.py`; test `tests/test_proposals_store_cross_process_lock.py`, new). The log is encrypted as one envelope, so every append is a full read-decrypt-rewrite, and it was guarded by an in-process `RLock` only — the store's own docstring said cross-process coordination was "not provided". With 10+ servers on one `~/.ziya`, two appends in the same window both read N events and both wrote N+1: the second rename won and the first process's event was gone, and because both used the same `.jsonl.tmp` path the loser crashed on a rename of a file the winner had already moved. A six-process test reproduced both the crash and the lost updates; the live log carried the other fingerprint, a duplicate `record` event for one id. An `fcntl` advisory lock on `probationary.jsonl.lock` now wraps every read-modify-write (`_append`, `prune_terminal`). It only serialises — nothing about which events are written changes — and the test asserts every append is present exactly once afterwards.
- Memory extraction no longer re-runs every earlier window of a conversation on every turn (`app/memory/extraction_cursor.py`, new; `app/memory/extractor.py`; tests `tests/test_extraction_window_cursor.py`, new). The server dispatches extraction after every stream with the full non-muted history, and topic windows are prefix-stable, so a 200-turn conversation paid triage plus ~25 extraction calls per turn for output dedup then discarded. Each conversation now records the hash of every window it has extracted (one file per conversation under `~/.ziya/memory/extraction_cursors/`, so concurrent servers never race); an unchanged window is skipped, and a window whose text changed — the trailing window growing, or a turn being muted — is extracted again. Lost or corrupt cursor state only costs a re-extraction.
- Every timestamp the model is shown now carries its UTC offset, in one form (`app/utils/session_context_prompt.py` `format_prompt_timestamp`, new; `app/utils/precision_prompt_system.py`; `app/utils/project_change_note.py`; test `tests/test_prompt_timestamps_carry_offset.py`, new). ``, `` and the per-message `` tags were naive local wall-clock times (`2026-10-23 23:51:04`) with nothing saying which clock they were on, while the project-change note in the same transcript was stamped UTC and tool output (`date`, git, file mtimes) carried its own zones; a model asked to reason about elapsed time had to guess the zone and spent turns reconciling them. All four now read `2026-10-23 23:51:04-07:00` (local time with offset) and the "Message Timing" paragraph in the system prompt says so. An aware `now=` passed by a caller keeps its own offset rather than being converted to the machine's.
- Reloading a browser tab now reopens the project that tab had open (`frontend/src/context/ProjectContext.tsx`, `frontend/src/utils/tabState.ts`; test `frontend/src/context/__tests__/tabProjectStickiness.test.ts`, new). The conversation id was already per-tab, but the project was remembered only browser-wide (`ZIYA_LAST_PROJECT_ID` in localStorage), so switching projects in one tab and reloading another moved the second tab to the first tab's project — and then off its saved conversation, which belonged to the original project. A per-tab, instance-scoped `ZIYA_TAB_PROJECT_ID` in sessionStorage is now consulted first on init and by `readLastProjectId()` (which ChatContext uses to scope the startup conversation list); new tabs still open the browser-wide last project, and a stored id the server no longer knows is dropped. A tab's own project also outranks the `--root`/`--directory` startup project on reload: that rule is for new sessions.
- The streaming Bedrock client never recovered from "input length and `max_tokens` exceed context limit", although `CustomBedrockClient` documents that it shrinks `max_tokens` and retries (`app/utils/custom_bedrock.py`; tests `tests/backend_system_tests/model/test_custom_bedrock.py`). The reduction sat in an `elif` reachable only with a conversation id, on an extended-context model, for an error that was NOT an overflow — so it never ran for the one error it parses. It now runs after the extended-context branches. The non-streaming path was unaffected.
- `ziya chat` on `--endpoint openai` with a GPT-5.x / 6.x model failed its no-tools path and the `/quit` tangent summary with `400 Unsupported parameter: 'max_tokens'` (`app/agents/wrappers/openai_direct.py` (`DirectOpenAIModel(max_tokens_param=)`), `app/agents/models.py` (`_initialize_openai_model`); test `tests/test_openai_direct_max_tokens_param.py` (new)). The provider path already sent the model's `max_tokens_param`; the legacy wrapper `ModelManager.initialize_model` builds hard-coded `max_tokens`. Found by the real-API capability tests.
- Stale tests under `tests/backend_system_tests/model/`: ten Nova wrapper modules imported `app.agents.nova_wrapper`, which moved to `app.agents.wrappers.nova_wrapper` in 0.3, and two computed the source path from the wrong directory; `test_ziya_bedrock.py` followed the server-default model, so once that became Sonnet 5.5 (no sampling parameters) its temperature/top_k checks failed. The Nova suites go from 0 to 31 passing; the rest is listed in the session notes, not fixed.
- `tests/backend_system_tests/model/test_model_connections.py` asserted nothing (every test patched `ModelManager.__init__` and read back what it had just set) and errored on fixtures no conftest has defined since `01c97e40`. It now resolves every configured model, every alias (against its target) and every endpoint default through the real `ModelManager.get_model_config`, offline: 160 tests.
- `get_skill_details` could not load any project or user skill by name (`app/mcp/tools/skill_tools.py`; covered by the previously failing `tests/test_project_skill_wiring.py` and `tests/test_skill_resources.py`). The lookup called an undefined `_request_project_root()`; the `NameError` was caught and logged at debug level, so the model was told "Skill 'X' not found" and offered only the built-ins. It now uses `get_project_root_or_none()`.
- Skills inside grouping folders are actually discovered (`app/services/skill_discovery.py` (`iter_skill_dirs`, `MAX_SKILL_DEPTH`), `app/services/skill_sources.py` (`LocalFolderSource._dir_for`, `_version_of`); `tests/test_nested_skill_discovery.py` now collects and passes). The 0.8.10.0 entry and `Docs/Skills.md` described this, but the walker was never committed, so every scanner still read only a root's immediate children and the test module failed at import. A local or git skill source also now finds a skill by leaf name instead of `root / skill_id`, which no longer resolves a `../` id outside the source.
- `tests/backend_system_tests/model/test_model_connections.py` and `test_model_transactions.py` failed at collection with `KeyError: 'sonnet'`; they now resolve the alias through `MODEL_ALIASES`. Their tests still error at run time on fixtures (`mock_aws_credentials`, `setup_test_environment`) that no conftest has defined since refactor `01c97e40`.
- Memory relation extraction threw away the most connected batches (`app/memory/organizer.py` (`RELATION_MAX_TOKENS`, `_salvage_json_array`); tests in `tests/test_memory_organizer.py`). The relation reply was capped at 1024 tokens. With memory ids that dense, that is about 29 relations, and a 20-memory batch often returns more. The reply was cut off mid-string, `json.loads` rejected the whole array, and the batch stored nothing, which showed up as `Relation extraction: JSON parse failed: Unterminated string …` on roughly one batch in five. The cap is now 4096 tokens. A reply that is still cut off keeps every complete relation before the cut and logs `reply truncated … kept N complete relations`. A reply that is not JSON at all still logs the original warning.
- On a fresh install, dragging a skill onto the Bench's on-hand lane failed with "Could not move …" and a `404` on `PUT /bench/place` (`app/api/bench.py` (`_view_model` now takes `project_id`), `app/services/bench.py` (`conversation_known`); tests in `tests/test_bench_api.py`). The hub opens on the conversation lens whenever a chat is open and relies on the server's `lenses` list to fall back to `project` when that chat has no record, but the view model listed the lens whenever an id was *passed*, not when the chat *existed*. A brand-new "New Conversation" is an empty shell the client never sends to the server, so every drop wrote to a conversation layer that `ChatNotFound`-ed. The lens is now offered only for a chat the server holds; an unsaved chat lands placements at the project layer, and the conversation lens appears once the chat has a message.
- A long list of always-on skills filled the top of every sidebar tab (`frontend/src/components/ActiveContextBar.tsx`; test `frontend/src/components/__tests__/ActiveContextBar.stack.test.tsx` (new)). The active-context bar is mounted above the file tree, the skills tab, the chat list and the backlog, so sixteen on-hand skills wrapped into six rows of pills on each tab and pushed the chat list below the fold on a laptop screen. The bar now shows the first four always-on skill pills and stacks the rest behind one `+N skills ▸` chip whose tooltip names them; clicking it unstacks (restoring each pill's X). A `▴` control folds the whole row to one line — a colour dot per always-on skill (twelve, then `+N`), a dashed ring per skill the model loaded on demand this turn, and a `16 on hand · 1 ✦` count; clicking the line returns to whichever of capped/all was in use. Both choices are remembered per project. With four or fewer always-on skills nothing stacks and neither control appears. Solid pills still mean "sent with every turn"; a dashed ✦ pickup is never stacked.
- The model was intermittently shown doubled braces in the file context — `{{a,b}}` for `{a,b}`, `${{x}}` for a TS template literal `${x}` — and could carry them into its answers and diffs (`app/utils/precision_prompt_system.py`; test `tests/test_codebase_braces_not_escaped.py` (new)). The codebase was brace-escaped before being passed as the `{codebase}` template *variable*, but `str.format` never re-parses substituted values, so the escape protected nothing and reached the model verbatim. A guard skipped it whenever any file in context already contained a `{{` (JSX `style={{…}}`), so the corruption appeared and vanished with the set of files in context: a markdown-only context was corrupted, the same file beside a `.tsx` was not. The escape is removed.
- Conversation search silently returned partial results ("missing terms") (`app/utils/chat_search_index.py` (new), `app/storage/chat_search.py` (`_candidate_files`), `frontend/src/components/MUIChatHistory.tsx` (`performSearch`), `frontend/src/utils/db.ts` (`lastSearchSource`); tests `tests/test_chat_search_index.py` (new), `frontend/src/components/__tests__/chatSearchStaleAndFallback.test.ts` (new)). Every search opened, decrypted and parsed every chat file in scope, which on a 2,350-chat install took 8.9 s for one all-projects search. The sidebar starts a search per debounced keystroke and never cancelled the earlier ones, so overlapping scans went past the browser's 25 s deadline, and the client then fell back to scanning IndexedDB, which only holds the bodies of conversations opened in that browser. Search now goes through an in-memory filter: one 6.3 KB bitmap per chat of the character trigrams in its text, built on first search and refreshed by file mtime and size, so writes by other processes are picked up. Only chats that can contain the query are read, and the matcher is unchanged, so results are identical to a full scan; warm, the filter takes about 20 ms. Queries with no three-letter run of `[a-z0-9_]` (for example `2.1%`) still scan in full. Concurrent searches wait for one build instead of each scanning. A response superseded by a later keystroke no longer overwrites the newer results, and a result from the local fallback now shows a warning that it may be incomplete. Kill switch: `ZIYA_DISABLE_CHAT_SEARCH_INDEX=1`.
- Reading another conversation by id took minutes when its turns were tool-heavy or it lived in another project (`app/mcp/tools/chat_history_tools.py`, `app/storage/chat_search.py` (`_candidate_files`), `app/utils/tool_history_rewrite.py` (`collapse_tool_blocks`); tests `tests/test_chat_read_tool_heavy_and_scoped_search.py` (new), `tests/test_chat_search_conversation_ids.py`). In the case that exposed it, one assistant message was 534,000 characters, nearly all tool output, and `chat_read` truncation kept only the first tool body, so the model never saw that turn's conclusions. Its fallback, `chat_search` with `conversation_ids`, found nothing until `all_projects=true` was added, and then every call decrypted every chat file in every project before applying the id filter (7–22 s per call, about 70 calls). `chat_read` now collapses tool blocks to one-line stubs by default (`tool_results="summary"`; `"full"` restores the raw view). Ids passed to `chat_search` are looked up directly through the chat index, in whichever project owns them, and only those files are read.
- A handed-off conversation showed two rows at its end, "The model drafted a handoff document" and "Handed off to …", both with the same "Continue in …" link. Once the "Handed off to …" notice exists, the drafted-document notice is no longer shown (`isSupersededHandoffNotice` in `frontend/src/components/HandoffNotice.tsx`, applied in `Conversation.tsx`). A conversation handed off before that notice existed still shows the drafted notice as its forward link.
- A parallel tool group's container swallowed every later tool call and paragraph of the message. The server relay dropped the `tool_group_end` event, so no group was ever closed. The relay now forwards it in both the main and the diff-validation retry loop, and a group whose end marker is missing now closes once all its members have arrived, which also repairs messages already recorded without it.
- **The release sweep no longer blocks a release on a group whose work already shipped, or defers files over a previous release's triage** (`scripts/release_plan.py` (`batches` mode), `design/sweep-orchestrated.task-card.json` (P0, P3a, commit loop, P5); tests `tests/test_release_plan_batches.py` (+2), `tests/test_sweep_orchestrated_card.py` (+2)). Run cli-188224db41e6 committed 222 groups, then stopped at the release gate for two reasons. First, one commit also carried a file the plan assigned to another group. That group then found nothing to stage, took the "already committed" exit without recording its id, and the gate kept treating it as outstanding. A group whose content is already at HEAD (whole files clean, or every patch reverse-applies) is now recorded with the commit that carries it, by the commit loop and again by the residue stage. Second, 56 paths were deferred because triage batches from the previous release still counted them as triaged, although their verdicts named entries the current Unreleased section lacks. P0 now archives triage batches older than the last release tag, and `release_plan.py batches` re-batches any `assign` verdict whose entry is no longer in the index, listing it under `stale_assigns`. The next run (cli-db2df7ec03ba) then stopped at P4a because every entry was already committed or deferred and the commit-plan contract demanded at least one group; the contract now also accepts an empty roster carrying a non-empty `already_committed_entries` list, so the run reaches P6, which is the stage that reports the deferral. The commit loop and residue sweep also carry a `time_budget_secs` (600 s and 900 s), after one Bedrock stream in the serial loop sat silent for 26 minutes (cli-4d15fd5c2684) before the 900 s thinking ceiling and 300 s socket timeout released it. (`design/sweep-orchestrated.md`; tests `tests/test_sweep_orchestrated_card.py` (+2))
- **Moving the conversation you are viewing to another project now takes you with it** (`frontend/src/context/ChatContext.tsx`, `frontend/src/components/MUIChatHistory.tsx`; test `frontend/src/context/__tests__/moveFollowsConversation.test.ts`). Previously the move dropped the conversation from view and selected a different conversation in the source project. Now the move saves locally and on the server, switches to the target project, and keeps the conversation on screen and selected while the rest of that project loads. Moving any other conversation works as before. A moved conversation that was global now has the flag cleared on the server as part of the move. The sidebar used to run a separate toggle after the move, and with the conversation now kept in view that toggle would have made it global again.
- **Shell env-prefix bypasses closed** (`app/mcp_servers/shell_server.py`; test `tests/test_shell_env_prefix_path.py`). `PATH=/tmp ls` was accepted because the allowlist matched `ls`, but the child resolved it through the prefixed PATH and ran `/tmp/ls`. A `PATH=` prefix is now allowed only when it resolves the command to the same file the normal PATH does, so `PATH=/opt/x:$PATH cmd` still works; a PATH built from any other expansion is refused. Inside `if`/`while`/`for` bodies, a segment starting with `NAME=value` was skipped as an assignment, so `FOO=1 anycmd` ran anything and `LD_PRELOAD=` escaped the loader-hijack block. The prefix is now peeled and the command behind it checked, as at top level.
- **Startup warns about the obsolete PyPI `asyncio` backport** (`app/utils/asyncio_backport_check.py`, wired in `app/main.py`; test `tests/test_asyncio_backport_check.py`). `asyncio` 3.4.3 from PyPI (declared by some third-party packages, e.g. `mcp-shell-server`) does not parse on Python 3.7+. Any environment that puts site-packages ahead of the standard library imports it and fails with `SyntaxError: tasks.async(...)` inside an unrelated import. The server now prints its location and the `pip uninstall asyncio` command at startup. Warning only.
- **Shell allowlist accepts `read`, and quoted or absolute command words** (`app/mcp_servers/shell_server.py`, `app/config/shell_config.py`; test `tests/test_shell_command_word_and_read.py`). `… | while read line; do …; done` was refused with `'read' is not allowed`. `read` is now in the built-in floor; both `sh -c` routes run with stdin closed, so it can only read what the command itself pipes in. `"ls" -la` and `/bin/ls -la` were refused although `ls` is allowed. The command word is now unquoted, and an absolute path counts as its basename only when PATH lookup resolves to that same file, so a copy written elsewhere (`/tmp/ls`) is still refused. The aws, curl and IaC guards see the normalized word, so `"/usr/bin/curl" ` is refused exactly like `curl `. Refusals now name the whole command (`'/Applications/Google Chrome.app/…/Google Chrome' is not allowed` rather than `'"/Applications/Google'`).
- **`~/.ziya/file_states.json` backups are bounded, and the state file is written atomically** (`app/utils/file_state_manager.py`; test `app/utils/tests/test_file_state_backups.py`). When the state file couldn't be read at load, it was moved to `file_states.json.backup.` and tracking was reset. Nothing ever read or deleted those backups, and one install had 476 of them (3.6 GB, the largest 1 GB). Only the newest 5 are now kept, and older ones are pruned at the next server start. The state file was also written in place, so a second server sharing `~/.ziya`, or this server's own next load, could read a half-written file, fail to decrypt it and discard the live state as "corrupted". Saves now write a temp file and rename it over the original.
- **Production frontend builds drop `console.log` / `console.debug` / `console.trace`** (`frontend/craco.config.js`, `frontend/src/context/ChatContext.tsx`, `frontend/src/plugins/d3/vegaLitePlugin.ts`; test `frontend/src/utils/__tests__/consoleStripBuild.test.ts`). About 1,500 log calls shipped, several on per-flush and per-save paths, and once DevTools has been opened the console keeps every logged object alive for the life of the page. Terser `pure_funcs` now removes the calls in production builds; arguments with side effects still run. `console.warn`, `console.error` and `console.info` are kept, and development builds are unchanged. `window.debugChatContext()`, `debugIndexedDB()` and `debugVegaLite.inspect()` print through `console.info`, so they still work in production. Three temporary `console.trace` diagnostics were removed, including one that ran on every save. The server's end-of-turn `COMPLETE MODEL RESPONSE` log is Python logging and is unaffected.
- **The production console strip now catches the log calls it missed, and keeps `render_diagram`'s rewrite notes** (`frontend/craco.config.js`, `frontend/src/utils/colorUtils.ts`, `ChatContext.tsx`, `StopStreamButton.tsx`, `StreamedContent.tsx`, `plugins/d3/*`, `D3Renderer.tsx`, `utils/diagramUtils.ts`; codemod `scripts/console_info_rewrites.py`; tests `frontend/src/utils/__tests__/consoleStripBuild.test.ts`, `frontend/src/utils/__tests__/rewriteNotesSurviveStrip.test.ts`, `tests/test_console_info_rewrites_codemod.py`). Terser's `pure_funcs` only drops a literal `console.log` whose value is unused, so two shapes survived: an alias (`colorUtils`' `const log = debug ? console.log : …`, which printed every mermaid/graphviz/drawio contrast line, about 230 lines per stream end) and expression-bodied arrows (`m => console.log(m)`). Both are now block-bodied, `console.group`/`groupCollapsed`/`groupEnd` are stripped too, and a source scan fails the build's tests if either shape comes back. Stripping `console.log` had also silenced the `🔧 TAG: …` lines the headless renderer reads to tell the model how preprocessing changed its spec, because `/render` loads the production bundle. The 177 such reporters in the diagram code now print through `console.info`, which survives the strip; the other 🔧 logs (chat, sync, sidebar) stay stripped.
- **Collapsed tool results no longer highlight or parse their body on mount** (`frontend/src/components/MarkdownRenderer.tsx`; test `frontend/src/components/__tests__/toolBlockLazyHighlight.test.tsx`). `GenericToolBlock` shows only the digest preview while collapsed, yet ran Prism and `marked.parse` over the full payload on mount and kept the HTML in state, for every file read and MCP result in history. Its header effect also inline-parsed the whole payload when there was no summary, for a header only hierarchical search results display. Both body effects now wait for the first expand, and the header parse runs only for hierarchical results. Short results that never collapse still highlight on mount, and the 150 K-character highlight cap stays for expanded results. Shell and shadow output render through `TerminalPane` and are unaffected.
- **Saves and cross-tab updates no longer read the whole IndexedDB store** (`frontend/src/context/ChatContext.tsx`, new `frontend/src/utils/idbTargetedRead.ts`; test `frontend/src/utils/__tests__/idbTargetedRead.test.ts`). `queueSave` read every conversation in every project, with full message bodies, as the base for its merge on effectively every save (React state almost always holds deferred shells, which disabled the cached path), and the `conversations-changed` broadcast handler did the same in every other open tab on every save. Both now read only the conversation ids they act on (`changedIds` / the broadcast's `ids`), which produces the same written records because nothing outside those ids reaches the write. Saves without `changedIds` still read the whole store. The 5-minute other-project cache, whose branch became unreachable, is removed.
- **A reloaded tab reopens its conversation even when IndexedDB holds no conversation list** (`frontend/src/context/ChatContext.tsx`, `ProjectContext.tsx`; test `frontend/src/context/__tests__/restoreAfterPlaceholder.test.ts`; diagnostic `scripts/mem_probe/startdiag.mjs`). With no IndexedDB shells (cleared site data, an IndexedDB repair, a restored tab whose session storage outlived its IndexedDB), startup ended with an empty list and set a placeholder id; the restore effect only ran when there was no current id, so the saved conversation was never selected and the tab showed "No messages in this conversation yet" until, 60 s later, recovery switched to the most recent conversation instead. The placeholder is now recorded and replaced by the restore while unused, and the restore opens the conversation through `loadConversation` so a deferred shell loads its messages. The startup project filter and server fallback also read the instance-scoped last-project key (new `readLastProjectId()`); the unscoped key they read before is never written.
- **Committed diffs no longer rebuild when another turn starts or ends streaming** (`frontend/src/components/MarkdownRenderer.tsx` `DiffViewWrapper`; test `frontend/src/components/__tests__/committedDiffStreamingRemount.test.tsx`; probe `scripts/mem_probe/probe.mjs --domtrace`). `DiffViewWrapper` chose between two render branches with different element trees using the conversation-wide streaming flag, which flips for every message at send and at stream end. On a 100-message conversation with 12 diff-bearing messages, each flip tore down and rebuilt ~23 K elements of history and the tab's DOM node count briefly went from 65 K to 139 K at send and 180 K at stream end, with nothing on screen changing. The wrapper now follows the streaming state of the message that owns the diff, which is always false for history, so only the diff still arriving in the live turn takes the streaming branch.
- **A reader scrolled up is no longer pulled to the bottom when a reply finishes** (`frontend/src/hooks/useScrollManager.ts`; test `hooks/__tests__/useScrollManagerNotify.test.tsx`). When a stream ended, its streamed-content entry was cleared and the scroll manager read the length dropping to 0 as "a new user message was sent": it forced a scroll to the end 100 ms later and cleared the "completed while you were away" indicator the moment it appeared. Measured with `scripts/mem_probe/streamend.mjs --where reading`, the message being read moved 3,465 px in 11 jumps. The drop now only re-baselines the length, so the reader stays put and the indicator stays until they scroll down or click it; the next turn's "new content below" indicator also works when the reply is shorter than the previous one. Sending a message and switching conversation still scroll to the end (`App.tsx`), and a user pinned to the bottom is still held there while the finished message settles.
- **Streaming no longer re-renders the whole app on every flush** (`frontend/src/context/StreamedContentContext.tsx` new; `ActiveChatContext.tsx`, `ChatContext.tsx`, `hooks/useScrollManager.ts`, `hooks/useSendPayload.ts`, `components/App.tsx`, `Conversation.tsx`, `StreamedContent.tsx`, `SendChatContainer.tsx`, `ExportConversationModal.tsx`, `RawMarkdownView.tsx`, `MarkdownRenderer.tsx`; tests `context/__tests__/streamedContentIsolation.test.tsx`, `hooks/__tests__/useScrollManagerNotify.test.tsx`). `streamedContentMap` (replaced every 150 ms) and `reasoningContentMap` (replaced on every thinking delta) were part of the `ActiveChatContext` and `ChatContext` values, and `App` read the map directly, so every flush re-rendered `App`, the sidebar, the modals and every history message. A sampled heap profile of one 100 s stream measured 18.4 GB allocated (~180 MB/s), almost all antd/MUI styling and React bookkeeping, and V8 old space filled with the promoted garbage (108 → 471 MB in 80 s) faster than it was collected — the main cause of browser windows growing to tens of GB with a few streaming tabs. The maps now live in their own context: only `StreamedContent`, live thinking blocks and an open raw view subscribe; `Conversation` reads a presence set that changes only when a stream starts or ends; send and export read the maps at call time through a ref; and `App`'s scroll manager is fed by a child watcher (`useScrollManager` gains a stable `notifyContentLength`; `contentLength` is now optional). A second per-chunk trigger went through the same contexts: `chatApi` calls `setProcessingState('idle')` on every content chunk, and `updateProcessingState` built a new Map each time even when the state was unchanged, giving `getProcessingState` a new identity and re-rendering the sidebar, header and closed modals per chunk. It now returns the existing Map when the state is unchanged (test `context/__tests__/processingStateNoOp.test.ts`). Same stream, same 100-message conversation, before → after both changes: 18.4 GB → 0.5 GB sampled allocations (186 → 4.9 MB/s), JS heap peak 575 → 159 MB, renderer RSS peak 1.43 → 0.90 GB.
- **Launching delegates no longer downloads every message in the project** (`frontend/src/utils/delegateLaunchSync.ts` new; `frontend/src/components/DelegateLaunchButton.tsx`; test `frontend/src/utils/__tests__/delegateLaunchSync.test.ts`). After a launch, the button refreshed the sidebar by calling `listChats(projectId, true)`, which returns every chat in the project with its full message history (363 MB and 25–33 s on a large project), and parsed all of it in the tab to find the few new chats. The launch response already names them (`orchestrator_id` and `conversation_ids`), so only those ids are now fetched with `bulkGetChats`. If the fetch fails, the periodic sync picks the chats up.
- **Streaming code blocks no longer rebuild their DOM on every flush** (`frontend/src/components/MarkdownRenderer.tsx` `CodeBlock`, `renderTokens`; test `frontend/src/components/__tests__/codeBlockStreamingDomChurn.test.tsx`; probe `scripts/mem_probe/probe.mjs`). Browser windows with a few Ziya tabs swelled to tens of GB. Measured with a headless CDP probe: idle tabs allocate ~0.2 MB/100 s, but a streaming tab's renderer RSS sawtoothed 0.4 → 2 GB while its JS heap stayed under 300 MB — the growth was Blink-side DOM garbage. Each 150 ms flush re-ran Prism on the still-open code block and wrote it via `dangerouslySetInnerHTML`, then an effect ran Prism again, serialized `innerHTML` and wiped/rebuilt the subtree a second time, destroying and recreating every highlighted span; closed blocks earlier in the message also re-ran Prism on every flush. The arriving block now renders as a single text node (updated in place) and is highlighted once when it closes, settled blocks memoize their highlight on the text, and tool results over 150 K characters are shown unhighlighted (a 300 KB result blocked the main thread for 34 s).
- **Autoscroll lands on the real end, not the top of the last block** (`frontend/src/hooks/useScrollManager.ts`; test `frontend/src/hooks/__tests__/useScrollManagerFollow.test.tsx`, new). Follow mode pinned to the bottom in a rAF when the streamed text changed, but a tool fence or thinking block renders its body a frame later, and while "Waiting for model response…" nothing changes the text at all, so the view sat on the block's header with the body hidden beneath the sticky composer dock. The hook now also observes the content box with a `ResizeObserver` and re-pins on any layout growth while following — during the stream and for 1.5 s after it ends — and no longer reads the browser clamping `scrollTop` after a shrink (a thinking block collapsing) as the user scrolling away. Scrolling up still leaves follow mode; returning to the bottom re-enters it.
- **Header's signature poll no longer floods the log with "escalation denied"** (`app/utils/scope_approvals.py` `is_scope_authorized_with_reason(log=)`, `app/api/task_cards.py` `_escalation_rows`; tests `tests/test_scope_authz_query_path_is_quiet.py`). The server log repeated dozens of `SCOPE_AUTHZ: no approval record for task 'b-…' — escalation denied` lines every few seconds although no task was escalating: the Task Cards header indicator polls `/task-cards/signature-summary` on every window focus from every open tab, and that read-only query asked the approval predicate about every escalating block of every card, which logged each "not signed" answer as a refused escalation. The two UI queries (`/signature-summary`, `/{id}/scope-status`) now ask with `log=False`; the reason code they display is unchanged, and the runtime gate (`is_scope_authorized`, used by the task runner, `ziya-approve` and the scope audit) still logs every real denial.
- **Offline no longer reported as a credential failure** (`app/providers/bedrock_client_cache.py`, `app/utils/custom_exceptions.py` `ProviderUnreachableException`, `app/utils/infra_errors.py` `is_auth_failure`, `app/server.py`; tests `tests/test_bedrock_client_cache.py` `TestCredentialPreflightClassification`, `tests/test_infra_errors_credential_network.py`). With DNS or the network down, the Bedrock client's STS pre-flight raised `EndpointConnectionError`, which was rewrapped as "AWS credentials check failed: Could not connect to the endpoint URL" and shown in chat as the re-authenticate banner. A network-shaped pre-flight failure now raises `ProviderUnreachableException` (a `KnownCredentialException` subclass, so the startup handlers still keep the server up) whose message names the STS host and says to check the network; `is_auth_failure` returns False for it and the stream emits `error_type: connection_error`, which the frontend renders as *Connection Failed* with a retry button. Expired/invalid tokens still classify as auth.
- **Local model server out of memory gets its own banner** (`app/utils/provider_error_chunks.py` new, `app/streaming_tool_executor.py`, `frontend/src/utils/modelMemoryErrorBanner.ts` new, `frontend/src/apis/chatApi.ts`; tests `tests/test_provider_error_chunks.py`, `frontend/src/apis/__tests__/chatApiModelMemoryError.test.ts`). An oMLX prefill memory-guard rejection (or a llama.cpp / Ollama OOM) arrived as an untagged error chunk, so the server relayed `error_type: unknown` and the chat showed the generic collapsed "Error Details" block with the raw text. Non-auth provider errors are now shaped by `build_provider_error_chunk`, which tags recognised memory-ceiling messages `model_memory_error`; the frontend renders *Model Server Out of Memory* with the server's needed-vs-limit figures pulled out, the three remedies (shrink the prompt, free RAM, raise the server's ceiling) and a retry button, with the raw text under *Technical details*. Checked ahead of the context-size banner, since the oMLX text also says "reduce context length"; unrecognised messages keep the previous untagged shape.
- **Model picker re-reads local servers and marks the loaded model** (`app/utils/local_models.py` `SCAN_UI_MAX_AGE_S`, `scan_is_stale`, `choose_default_model`; `app/routes/model_routes.py`; `frontend/src/components/ModelConfigModal.tsx`; tests `tests/test_local_picker_refresh.py`). Models added to an oMLX library never appeared in the model modal: a non-empty local-server scan was kept for the life of the process (only an empty scan had a TTL), and the picker routes asked for the memoised registration. `/api/available-models` and `/api/endpoints` now request a scan no older than 2 s, so opening the modal re-reads the server while the several requests one open makes share one scan; chat-turn callers keep the never-expires contract and are never stalled by a port probe. A rescan also re-runs per-model discovery, so the server's `loaded` state (oMLX `/v1/models/status`, LM Studio `state`) lands on the model entry, is relayed per model, and the picker lists loaded models first with a green *loaded* tag. The endpoint's default model prefers the loaded one, so switching to a local endpoint does not evict a 100 GB model; `ZIYA_LOCAL_MODEL` still wins.
- **A redirection on a compound closer was refused as a command** (`app/mcp_servers/shell_server.py` `_validate_compound_body`; test `tests/test_shell_compound_closer_redirect.py`, new). `for f in *.md; do …; done 2>/dev/null | head` was blocked with `'2>/dev/null' is not allowed`: the compound validator strips the `done` keyword from the segment and then treats the next word as the command, so the loop's own redirection (`done 2>/dev/null`, `fi 2>&1`, `} > /dev/null`, `done < file`) became a bogus command word. Redirection tokens are now skipped before the allowlist match; a closer carrying only redirections has no command to check. The redirection target is unchanged in effect — the write policy's independent `_redirection` scan still refuses `done > app/out.txt` and permits `.ziya/` and `/dev/null`.
- **Non-prefill continuation prompts carried the answer twice, and the open-fence verdict is now taken from the complete text** (`app/streaming_tool_executor.py` inject branches, `_fence_open_in_full_text`; test `tests/test_no_prefill_continue_conversation_shape.py`, new). On a model without assistant prefill, each of the `max_tokens` / intent / block-completion continuation branches appended `assistant_text` after the iteration-end block had already appended that turn; the pre-send coalesce merged the pair into one `assistant[text,text]` message, so the model saw its own reply duplicated ("the text was accidentally printed twice") before the `[System: …]` prompt. The branches now append only when no assistant turn is already last. Separately, the "cut off mid-code-block" decision used the incremental streaming tracker's `in_block`, which has reported an open fence on complete responses (a closed ```` ```mermaid ```` block, `transitions=1`); the verdict is now re-derived in one pass over the finished text, and a `FENCE_STATE_DIVERGENCE` warning records the incremental state when the two disagree so the next case is attributable. Known, not yet fixed: after a block-completion continuation the next iteration's tracker starts fresh, so the model's bare closer is read as an opener and a further continuation fires (bounded by the stall cap).
- **Service calls on a local endpoint evicted the chat model** (`app/services/model_resolver.py` `_local_service_model`; test `tests/test_local_service_model_affinity.py`, new). Memory triage, extraction, the intent judge and the tier-declared `improve_judge` resolved to the local server's *default* model — its first listed one — rather than the model the conversation was running. Observed on oMLX: after turn 1 on a 94 GB Qwen, triage asked for GLM-5.3 (103 GB), oMLX evicted Qwen to load it, and turn 2 failed with 507 "Cannot load … projected memory would exceed the ceiling". Local endpoints now use `ZIYA_MODEL` for every service category when the server lists it (env overrides such as `ZIYA_MEMORY_EXTRACTION_MODEL` still win); the server default applies only to a model the server does not have.
- **Complete reply judged "cut off mid-code-block" on fine-grained streams** (`app/text_delta_processor.py` viz-block flush, `app/streaming_tool_executor.py` tool_use flush; test `tests/test_fence_tracker_viz_flush_seam.py`, new). The content optimizer's `flush_remaining()` before a ```` ```mermaid ```` (or other viz) block and before a `tool_use` block yielded the held text to the user but never fed it to the fence tracker. With small deltas (claude-fable-5-1) the tracker was left holding a stale partial line (`## What `), glued the whole viz buffer onto it (`## What ```mermaid`), missed the opener and read the closer as a bare opener — `in_block=True, transitions=1` at a clean `end_turn`, so `NO_PREFILL_BLOCK_CONTINUE` asked the model to finish a block it had closed, and the reply gained a paragraph explaining nothing was cut off. Both flushes now feed the tracker first, matching every other optimizer drain.
- **Three diff-applier defects behind one docs-edit failure** (`app/utils/diff_utils/pipeline/pipeline_manager.py`, `application/git_diff.py`, `application/patch_apply.py`, `validation/validators.py`; test `tests/test_diff_countless_header_backtick_escape.py`, new). A three-file documentation patch had two files rejected as "Malformed hunk detected" and the third reported applied while it kept a removed line and dropped a kept one. (1) The difflib stage, the atomic git path and `_is_valid_hunk_header` required four numbers in the hunk header, so the count-less single-line form `@@ -63 +63 @@` — valid unified diff, and the form the system prompt asks the model to use — was rejected; the parser had accepted it all along. (2) The `\`` transport escaping the model is told to preserve was never undone before the `patch` and `git apply` stages (the frontend forwards the fence verbatim), so every hunk touching a line with a backtick failed those stages and reached difflib as its last chance. The pipeline now unescapes at entry, grounded in the target file so a template literal's genuine `\`` is kept. (3) difflib's context-preservation walk, entered whenever old and new line counts happen to be equal, consumed at most one old line per added line; a hunk that adds one line and replaces two with one misaligned by one and copied its trailing context from the wrong file line, with the stage reporting success. The walk now follows the hunk's raw `' '`/`-`/`+` markers. The streaming verifier's rejection of the third file was correct — it saw the corrupted result — and now reads "already applied" after a clean apply.
- **Overlapping-hunk merge sized its region by header counts, duplicating lines past an undercounted header** (`app/utils/diff_utils/application/overlapping_hunks_fix.py` `merge_overlapping_hunks`; test `tests/test_diff_overlap_merge_undercounted_header.py`, new). Found while chasing the follow-up from the fix above: on the live fallthrough path (patch and git apply both failed) the difflib stage receives the model's header counts unrecounted, and the parser repairs an undercount only when it is 2+ lines on both sides. When two hunks are merged as overlapping, the merged old region ended at the header-declared count of the hunk that started last, while the per-hunk splice replaced `len(old_block)` lines — so a body that ran one line past its header count pushed the splice off the end of the region and every line past it came out twice. The stage reported success, and a re-apply reported "already applied", so the streaming verifier could not catch it. The region now ends at the furthest of each hunk's declared-or-actual extent. A survey of the other `old_count` consumers in the stage (search windows, truncation rescue, already-applied probes) found no further divergence between recounted and raw headers across 20 single- and two-hunk miscount shapes.
- **Out-of-policy write approvals now actually learn, and say what they remember** (`app/utils/consent_grants.py` (new), `app/utils/consent_gate.py`, `app/utils/consent_ledger.py`, `app/api/consent.py`, `frontend/src/utils/consentStore.ts`, `frontend/src/components/ConsentPanel.tsx`, `frontend/src/components/WriteGrantsChip.tsx` (new), `SendChatContainer.tsx`; tests `tests/test_consent_grants.py` (new), `tests/test_consent_grants_api.py` (new), `ConsentPanel.test.tsx`; `design/scope-consent.md` D1–D4, `Docs/Capabilities.md`). Approving a write "for this conversation" or "always" felt like it remembered nothing: grants matched the exact file only (every new file asked again), lived in process memory (a restart forgot them, "always" included), and the panel's dropdown chose *how long* while fine print silently fixed *what*. The panel is now `Approve · for [scope ▾] [duration ▾]`: the scope dropdown walks from the file up its ancestors to the whole project, duration is this call / this conversation / this project, and any remembering choice shows the server-produced rule sentence that is stored verbatim. Grants match by prefix at the chosen rung, persist in `/.ziya/consent-grants.json`, never cover the floor (`.git`, install dir, outside root), and are listed and revocable from a *write grants* chip next to the input box. The panel reopens at the rung last chosen in the conversation. `session` and `always` scopes are retired (the API returns 422).
- **Parallel tool calls in one turn were almost never actually parallel** (`app/tool_batch.py`, `app/mcp/client.py`; tests `tests/test_tool_batch_classifier_quotes.py`, `tests/test_mcp_client_rate_limit_builtin.py`, both new). The per-turn batch runner overlaps consecutive read-only calls, but its shell classifier split commands on `|` *inside quotes*, so every `grep "a\|b"` became a serial barrier; measured, three ~50 ms greps took ~5 s. Classification now tokenises shell-style (quoted operators are arguments), understands `rg`, `jq`, `xargs `, `timeout N `, `time ` and `git check-ignore`, and treats observing builtins (`render_diagram`, `analyze`, `dataset_*`, `job_status`/`job_read`, `task_run_read`, `ledger_query`, `view_image`, `shadow_wait`, …) as overlappable. The MCP client's 2 s per-tool rate limit no longer applies to the builtin shell server (external servers keep it; `rate_limit_seconds` in a server config overrides either way). Audit data (2026-10-15..20) showed the runner does overlap co-issued reads but rarely gets any: 77% of tool batches were a single call and the median model round-trip between them was 9.3 s against sub-second tools. A per-stream tracker (`app/utils/serial_reads_nudge.py`) now attaches a system note to the tool-result turn on the second consecutive message holding exactly one read-only call (repeating every third message while the streak lasts), telling the model its reads would have run concurrently had it issued them together; any batched or non-read message resets it (`tests/test_serial_reads_nudge.py`, new). External `mcp_*` tools, previously always serial, now overlap when they only observe: by `permissions.json` (`parallel.read_only_tools` / `parallel.serial_tools`), by the server's `readOnlyHint` annotation (now carried from `tools/list` through `MCPTool` and `SecureMCPTool.metadata`), by the absence of a write-target argument (`savePath`, `output_file`, …) and finally by name, where any mutating word (`update`, `post`, `download`, `run`, …) beats any observing one (`get`, `search`, `read`, `list`, …); anything unclassified stays a barrier (`app/mcp/client.py`, `app/mcp/enhanced_tools.py`; tests `tests/test_tool_batch_external_tools.py`, new).
- A valid Mermaid flowchart failed with `Expecting 'SQE' ... got 'STR'` when a quoted label contained JSON-ish text such as `{backlog:[…], done:[…]}` (`frontend/src/plugins/d3/mermaidEnhancer.ts`; test `mermaidQuotedLabelColonBracket.test.ts`, new). The `colon-in-node-id-fix` pass scans for `id-with-colon[` and restarts after every `[` it consumes, so inside the quoted label it resumed at `done:` — a clean token none of its substring guards rejected — and quoted it as a node ID. Both the scan and the replacement now skip positions inside an open double-quoted string on the line.
- The two gantt label-colon passes now agree on one encoding (`mermaidEnhancer.ts` `gantt-label-colon-fix`; test `mermaidGanttLabelColon.test.ts`). The stricter `gantt-task-name-colon-escape` (priority 140) runs first and emits `#58;`, which mermaid decodes back to a real colon; the older fallback still emitted a fullwidth colon (U+FF1A, a lookalike glyph) for the lines whose data section the strict grammar rejects, and its suite had been red since the strict pass landed. The fallback now emits `#58;` too, and the suite asserts the chain's output plus a case only the fallback handles.
- Vega-Lite interval bars (`x`/`x2`, or `y`/`y2`) rendered nothing (`frontend/src/plugins/d3/vegaLitePlugin.ts` `applyRangedBarFixes`, new; tests `vegaRangedBar.test.ts` and `vegaRangedBarRender.test.ts`, both new — the latter compiles and renders through the real Vega-Lite/Vega pipeline and counts visible bars: 0 before the fix, one per episode after). Two Vega-Lite semantics models reliably get wrong: a `timeUnit` on the primary channel is a bucketing transform that moves `x` into the 2012 reference year while `x2` stays in the data's year (decade-wide axis, bars off-screen) and floors sub-unit intervals to zero width; and once `x2` is set, `y` is a point position, so a quantitative `y` with no `y2` is a default-thickness sliver that `cornerRadius` erases. The timeUnit is now translated to `axis.format` (`utc*` → utc scale) on every layer sharing the scale, and a quantitative height on an x-ranged bar gets `y2: {datum: 0}`. Gantt bars (nominal y), explicit `y2`, log scales and the y-ranged mirror are left alone.
- ```` ```chord ````, ```` ```force-directed ```` and ```` ```network ```` fences were shown as raw JSON: the fence scanner and plugin registry knew the languages but `determineTokenType` only routed the literal `d3` language. A shared `d3FenceLang` map now routes them to the D3 renderer and supplies the spec `type` when the body omits it; `chord-directed` is also marked atomic in the fence scanner.
- **Muting a message is now a versioned change, so it can no longer be silently undone by sync** (`frontend/src/context/ChatContext.tsx` (`toggleMessageMute`); test `frontend/src/context/__tests__/muteBumpsVersionWiring.test.ts` (new)). Mute was the one conversation mutation that did not stamp `_version`. Every place that decides whose copy wins is version-gated: the server's bulk-sync keeps its on-disk copy of matched messages when the push is older than the record, and the client reconcile lets a fetched copy replace the in-memory one on a version tie. A mute pushed while the server's version was ahead (routine after a streamed turn) was dropped, and the unmuted copy flowed back in, so the muted turn still rendered as muted but was sent to the model again. Observed as a fork of a conversation with a muted 225k-token response being rejected at over 1M tokens; toggling the mute off and on in the fork restored the smaller request.
- **A flamegraph, railroad or wavedrom block missing its closing fence no longer swallows the next diagram** (`frontend/src/components/fenceScanner.ts` (`ATOMIC_FENCE_LANGS`); test `frontend/src/components/__tests__/atomicFenceLangCoverage.test.ts` (new)). A `flamegraph` fence followed directly by a `mermaid` fence with no close in between was read as one flamegraph block, which failed with "line 11 has no trailing sample count" on the escaped mermaid opener. `repairAtomicFenceRuns` already closes a diagram block at the next column-0 opener, but its language set had not been extended when these three renderers (and their aliases) were added. The new test reads the diagram aliases out of `determineTokenType`, so a renderer added there without the repair now fails a test.
- **Mermaid xychart with time labels on the x-axis renders** (`frontend/src/plugins/d3/mermaidXychartAxisLabels.ts` (new), `mermaidEnhancer.ts` (`xychart-axis-label-quote`); test `__tests__/mermaidXychartAxisLabels.test.ts`). `x-axis [05:54:15, 05:54:16, ...]` failed with "Expecting ... got 'COLON'": the xychart lexer reads a bare band label as letters, digits and `+=*#_.&-` only. Band labels outside that set (colons, inner spaces) are now wrapped in double quotes; labels mermaid already parses are unchanged. The pass is registered for both `xychart` and `xychart-beta`; the existing xychart passes match `xychart` only, so none of them ran for a `xychart-beta` header.
- **Mermaid state diagrams with colons or semicolons in labels render.** A
  time in a note or transition label (`note right of X : at 05:54:19`) failed
  with "got 'DESCR'"; label colons and prose semicolons are now escaped and
  still display as written. The state-shape pass no longer declares phantom
  states taken from label text (`05`, `54`), and a `;` between statements or
  at the end of one no longer creates a state named `;` or `B;`.
- **Search-result lists choose raw code vs. markdown per row.** The first
  row's language used to decide for every row, so one markdown row made
  line-numbered code rows render as mangled markdown, and vice versa.
- **Escaped backticks and dollars inside double-quoted shell arguments now
  reach the program as bash would deliver them**
  (`app/mcp_servers/shell_server.py`). Bash removes the backslash before
  `$`, `` ` ``, `"` and `\` inside double quotes; the executor's `shlex`
  tokenizer did so only for `"` and `\`, so a SQL quoted identifier in
  `python3 graf_q.py "SELECT ... FROM \`table\` ..."` arrived as
  `` \`table\` `` and ClickHouse refused it, and `"\$HOME"` was expanded by
  the pre-tokenize variable pass instead of being delivered literally. The
  escaped pair is now swapped for a placeholder before expansion and
  restored per token; `$?`/`$$` expansion skips an escaped `$` too. The
  "write the query to a file first" workaround is no longer needed.
  Tests: `tests/test_shell_double_quote_escapes.py`.
- **A model could not retract a memory proposal it made last turn**
  (`app/mcp/tools/memory_tools.py`). `memory_propose` returned the
  `prop_*` id only as a sibling field; the chat record keeps just the
  message line, so on the next turn the model had no id to hand to
  `memory_retract_proposal` and fell back to "reject it when you review
  proposals". The message line now names the id, and
  `memory_retract_proposal` also accepts the proposal's `content` (exact
  match, or a unique substring among this conversation's own open
  proposals) and lists this conversation's open proposals when it cannot
  resolve a request. Same-conversation retraction has no gate; proposals
  from other conversations remain the user's to review.
- **Zero-argument external MCP tools rejected with "EMPTY ARGUMENTS"**
  (`app/streaming_tool_executor.py`). Tools such as a Jira server's
  `refresh_jira_auth` / `get_jira_myself` (schema `required: []`) were
  bounced client-side before reaching the server. `SecureMCPTool` carries
  the server's JSON schema only in `metadata['input_schema']` and has no
  pydantic `args_schema`, so the `optional_only_tools` probe (pydantic
  only) never classified any external tool; separately, the set stored
  `mcp_` while the gate compares the `mcp_`-stripped name. The probe
  now reads the dict schema first and registers both spellings.
- **document_authoring skill pointed at a hard-coded port.** The export
  recipe said `localhost:6969`; a task-card stage copied it, got a non-200
  from a port nothing listened on, declared the export server unreachable
  and fell back to pandoc while the real server (on another port) was
  answering `%PDF` the whole time. The skill now addresses
  `localhost:$ZIYA_PORT` (the server publishes its bound port into the shell
  tool's environment), requires a `head -c 5` → `%PDF` check, and only
  permits an "unreachable" conclusion after every `lsof`-discovered port has
  been tried and recorded. Docs/DocumentAuthoring.md mirrors the recipe.
- Rendered images inlined into tool blocks as `data:` URIs are elided from history before it is sent, so a conversation with several renders is no longer re-uploaded image by image on every turn or rejected by the request-size limit before the server's own elision runs (`frontend/src/utils/elideInlineImages.ts`, new; test `frontend/src/utils/__tests__/elideInlineImages.test.ts`).

### Changed
- Ziya's remote (HTTP/SSE) MCP client works with both `mcp` 1.x and 2.x, and new installs resolve to 2.x (`pyproject.toml` `mcp = ">=1.23.0,<3"`). SDK objects are read through `app/utils/mcp_sdk_compat.py`, which handles the 2.x snake_case renames and builds the HTTP client type the transport expects (`httpx` on 1.x, `httpx2` on 2.x). This fixes remote servers such as chorus-mcp failing with `'InitializeResult' object has no attribute 'protocolVersion'` when `mcp` 2.x was present. It also fixes three reads that 2.x would have broken without any error: tool errors (`isError`) reported as success, image MIME types reset to `image/png`, and `readOnlyHint` annotations dropped. Audio content is no longer labelled as an image. `RemoteAuthProvider` plugins declare `http_libraries` and build their client with `mcp_sdk_compat.http_module()`. A provider that cannot build the type the installed SDK needs is refused before connecting, with the reason (`remote_auth_sdk_mismatch`). Tests: `tests/test_mcp_sdk_compat.py`, `tests/test_mcp_remote_sdk_e2e.py` (a real in-process server; run under each SDK). The stdio client and built-in servers do not use the SDK and are unaffected.
- The Bedrock default model is Opus 5.5 again (`opus5.5`), replacing Sonnet 5.5. This affects only users who haven't picked a model; a model set with `--model`, `ZIYA_MODEL` or in saved settings still wins (`app/config/models_config.py` `DEFAULT_MODELS`).
- Mermaid upgraded from 11 to 12.2.0, and building the frontend now needs Node 22.12 or later (`.nvmrc` and `mise.toml` pin 24). Mermaid 12 changes the default look (`neo`) and lays graph diagrams out with ELK; Ziya pins the classic look so existing diagrams keep their appearance, and chooses the layout engine per diagram type. `scripts/mermaid_layout_eval.py` (new) renders a set of hard diagrams under both dagre and ELK in headless Chromium and scores each from the SVG (edge crossings, edges through nodes or labels, overlapping labels, size); results go to `.ziya/layout-eval/` (`frontend/package.json`, `frontend/src/plugins/d3/mermaidPlugin.ts`).
- Four music test suites that had gone stale against already-shipped renderer rules now follow them: `musicSystemWrap` (D-140 grows an undersized pinned width; D-138 ignores a pin past the legibility limit, with its own test), `musicOrnamentation` (a below chord symbol is compared with a bare note, since D-438 reserves a band for above symbols too), and the `musicWrapParity` snapshots (stave ink #6b6b6b from 2186d44f, grace-note slashes, ledger and chord-symbol headroom). Each was confirmed to fail identically on HEAD's renderer before it was changed.
- Within-reach MCP loads are cache-stable and can be per tool (`design/mcp-on-demand.md`, revision 2026-10-25; `app/utils/mcp_ondemand.py`, `app/utils/bench_prompt.py` `mcp_filter_for_root` / `apply_mcp_filter`, `app/models/chat.py` `loadedMcp`, `app/mcp/tools/mcp_loader_tools.py`, `app/streaming_tool_executor.py`, `app/api/bench.py`, frontend `CapabilityCard.tsx`; tests `tests/test_mcp_ondemand.py`, `test_bench_prompt_seam.py`, `frontend/.../benchLoadedChip.test.tsx`). The first cut derived "loaded" from a marker in history, so tributary folding or muting the loading turn un-loaded the server and the next use cost a second full-prefix cache miss; its catalog listed only connected servers, so a server connecting after turn 1 moved the system prompt; and a loaded server's tools were inserted mid-array. Now the loaded set is stored on the chat record (a fork copies it; the bench card shows "loaded" / "N tools loaded"), the catalog is built from placement and config alone, and the tool array is ordered builtins → on-hand servers by name → loaded servers in load order, so a load only appends. New `search_server_tools(query, server?)` ranks a within-reach server's tools without loading anything, and `load_server_tools` takes an optional `tools` list, cumulative per conversation, so one tool of a hundred-tool server can be loaded without the other ninety-nine.
- A task card's Schedule block no longer puts the card on the scheduler by itself; the user registers the card, and the registration (not the card) is what fires (`app/agents/schedule_registry.py`, new; `app/agents/task_scheduler.py` `_enumerate_scheduled_cards`; `app/api/schedules.py`, new; `app/server.py`; `app/api/task_cards.py` `/schedule-state` gains `registered` / `enabled`; `app/data/built_in_skills.py`; tests `tests/test_schedule_registry.py`, new, and `tests/test_task_scheduler.py`). The scheduler listed deck cards with an enabled Schedule block, so a card the model staged in chat (conversation-only) ran once on Run and never fired again, with nothing saying so; pausing meant editing the card. Now `POST /api/v1/projects/{pid}/schedules/{card_id}` registers a card with a `registered_at` time, copying the cadence from its Schedule block (or a daily 09:00 default for a card without one; the whole card is then the body) with the request body's edits on top; `PATCH` enables/disables it or changes the time and frequency, re-projecting the next fire at once; `DELETE` unregisters and drops the fire history; `GET` lists registrations with next fire, last outcome, fires, skips and `exhausted` (max runs reached), plus cards that carry a Schedule block nobody has registered, and `GET /api/v1/scheduler/status` reports which process holds `~/.ziya/scheduler.lock`. None of these touch the card (no version bump, nothing in its export). One registration per card; `/schedule_registrations.json`. Cards the old rules had armed (a `schedule_state.json` record whose card still has a Schedule block) are migrated once into registrations, paused if the block was disabled; a card with a Schedule block and no record was never armed and is listed as unregistered. The task-card skill no longer tells the model that launching a Schedule-rooted card registers it. The management UI (Scheduled list with register / pause / edit) is not in this change.
- The bench's Within reach and Shelved lanes list MCP servers and skills most recently used first (`app/utils/capability_usage.py`, new; `app/mcp/tools/skill_tools.py`, `app/mcp/manager.py` `call_tool`, `app/api/bench.py` (items gain `last_used`), `frontend/src/apis/benchApi.ts`, `frontend/src/components/Bench/laneFold.ts`, `BenchBoard.tsx`; tests `tests/test_capability_usage.py`, `frontend/src/components/Bench/__tests__/benchBoardRecency.test.tsx`, new, and `laneFold.test.ts`). They were in the server's catalog order, which reads as random. A use is a successful `get_skill_details` load or a call to one of a server's tools that passes the quarantine and permission checks; the time is kept per user in `~/.ziya/state/capability_usage.json` under a file lock, only ever moves forward, and is written at most once a minute per item per process. Never-used items follow in the previous order. On hand is unchanged. Built-in skills at their default still fold into the "N built-in skills" row, now ordered by recency inside it. `Skill.lastUsedAt` was not usable for this: it records creation and edits, not use.
- The MCP status modal lists External MCP Servers (or the "No external MCP servers configured" notice) above Builtin Tools and the builtin servers (`frontend/src/components/MCPStatusModal.tsx`; test `frontend/src/components/__tests__/mcpStatusModalSectionOrder.test.tsx`, new). The servers a user configured were below the builtin ones they rarely change.
- The handoff drawer's "Ask the model to draft it" now runs on the standard `medium` model tier by default, with a selector for another tier or the conversation's own model (`frontend/src/utils/handoffDraftModel.ts`, new; `frontend/src/hooks/useHandoffDraftRequest.ts`; `frontend/src/components/HandoffDrawer.tsx`; tests `frontend/src/utils/__tests__/handoffDraftModel.test.ts`, new, and `frontend/src/hooks/__tests__/useHandoffDraftRequest.test.tsx`). The drafting turn inherited the conversation's model pin, so a conversation pinned to a frontier model paid frontier rates for a summary the user then edits. The tier resolves through `/api/model-tiers` for the active endpoint (the same table Task Card blocks use) and pins the background turn per request; the choice is remembered per browser, and an unreadable tier table falls back to the conversation's pin rather than failing the request. The automatic draft written under context pressure is unchanged — it is part of the model's own turn.
- `test_large_context` calls models through the production provider path (`app/providers/factory.create_provider`) instead of the legacy LangChain wrapper, names provider errors and refusals, and uses a benign needle — Claude Sonnet 4.6 refused "find the secret code" with `stop_reason=refusal`. Real-API result: 65 passed; remaining failures are Google (402, prepaid credits exhausted), `bedrock/grok-4.6` (stream stall), and `openai/gpt-5.5-pro` / `gpt-5.4-pro` (Responses-only models on a Chat Completions endpoint — a product gap, not a test issue).
- The tributary fold is now gated on context pressure (`app/utils/tool_history_rewrite.py` (`FoldSettings.pressure_ratio`, `FoldPressure`, `FOLD_PRESSURE_HYSTERESIS`, `plan_folds(pressure=)`, `fold_pressure_for`, `record_fold_savings`), `app/utils/handoff_prelude.py` (`get_context_pressure_detail`), `app/models/project.py` (`contextManagement.fold_pressure_ratio`, default `0.8`), `app/server.py` (passes `conversation_id` to the rewrite), `app/utils/session_context_prompt.py`; tests in `tests/test_tributary_fold.py`). Folding every eligible hunt as soon as two turns had passed cost the model verbatim text it still had in view — the exact lines a diff's context is built from, the exact path a follow-up edits — long before the window was under any pressure, and it had to notice the loss to recall it. Nothing now folds until the conversation's *unfolded* replay reaches 80% of the context limit (the same per-conversation measurement the handoff nudge uses); then the oldest eligible turns fold first, until the projected size is back under 65%. The gate compares the unfolded size (the measured size plus what the previous turn's folds removed), so a fold never lowers the pressure that justified it: the folded set is a prefix that only grows, each placeholder is written once and stays in the cached prompt prefix, and a conversation never flaps between folded and unfolded. Unknown pressure (first turn after a restart) folds nothing. `fold_pressure_ratio: 0` restores the old fold-everything behaviour. The "Folded tool turns" paragraph in the Session Context no longer asserts that earlier turns *are* folded, since below pressure none are.
- **File-write results render as a real diff.** The body of a `file write:` tool block (a `file_write` patch) now goes through the same renderer as a chat diff fence — file header, unified/split and line-number controls, Prism syntax highlighting inside the add/remove colouring — with the "already written to disk" caption and no Apply button (`ToolDiffPreview` → `ReadOnlyDiffView` in `frontend/src/components/MarkdownRenderer.tsx`). The previous react-diff-view rendering drew removed and added lines interleaved, struck through and uncoloured; its palette in `styles/toolDiffApplied.css` is reduced to the caption. Test: `components/__tests__/toolDiffPreview.test.tsx`.
- **Steer record-shaped work to the dataset tools.** A review of task-card runs found the model building bespoke JSON stores and `python3 + json` aggregation scripts (5,000 snapshot copies of one backlog, 1,700 JSON files for one matrix) while `analyze` went uncalled. The shared session-context prompt now carries a standing `### Working with data` section (chat and task blocks alike, gated on the `datasets` category); the `dataset_register` / `analyze` descriptions open with when to reach for them; the `task_cards` skill's blackboard recipe is an append-only JSONL log read by `analyze` instead of a rewritten state object; and the card validator warns when a task's instructions maintain a hand-built JSON store and name no data tool.
- `dataset_register` / `analyze` accept the JSON shapes models actually write: an object of objects becomes rows keyed by `_key`, an object with one array-of-objects member reads that member, and `record_path=` (or `path#member` in `analyze` inputs) selects one when several qualify. Previously any top-level JSON object failed with a pandas "All arrays must be of the same length" error.
- **Shell `aws` subcommand policy narrowed and made explainable.**
  `aws s3 cp` / `aws s3 sync` are blocked only when they write *into* S3
  (upload, bucket-to-bucket); a download to a local path (`aws s3 cp
  s3://bucket/key /tmp/x`) is the same read as `s3api get-object` and now
  passes. `mv`/`rm`/`rb`/`mb` and the IAM/STS/deploy legs are unchanged.
  The refusal now says it comes from Ziya's built-in subcommand policy, not
  the command allowlist, so an approved `aws` in Shell Configuration no
  longer looks like a broken allowlist. And the policy defers to an explicit
  grant: if the verified allowlist names `aws`, `aws ` or
  `aws  ` itself, the user asked for it and the hardening
  steps aside. The grant is read from the signature-checked scope env, so an
  unsigned `aws` hand-written into `mcp_config.json` does not lift it.
- The context-budget bar in the sidebar footer and the collapsed strip is one shared component, stacked by what is spending the budget (files · chat · tools · ast), so the two can no longer disagree on colour or proportion; the track turns red past the danger threshold (`frontend/src/components/TokenBreakdownBar.tsx`, new; `frontend/src/components/TokenCountDisplay.tsx`; test `frontend/src/components/__tests__/tokenBreakdownBar.test.tsx`).
- Packet and force-directed graphs use the shared Open / Source / Save diagram actions, and every plugin's action cluster is styled by one CSS rule set, hidden until the diagram is hovered (`frontend/src/plugins/d3/diagramActions.ts`, `packetPlugin.ts`, `forceDirectedPlugin.ts`, `frontend/src/index.css`).

### Removed
- Removed obsolete tests under `tests/backend_system_tests/model/`: seven near-duplicate Nova wrapper modules (import, syntax, LangChain type-hint and runtime copies of `test_nova_wrapper.py`), a reproduction of a long-gone LangChain `AgentFinish` error (`test_nova_lite_streaming.py`), and five methods asserting `ZiyaString` / `ZiyaMessageChunk` behaviour those classes no longer have; also the one-off 2025 migration scripts `tests/strategic_integration_plan.py` and `tests/execute_integration.py`. `test_nova_wrapper.py` now follows the live `NovaWrapper` (base of `NovaBedrock`): its stream test stubbed `converse` while `_astream` calls `converse_stream`. `test_ziya_bedrock.py` follows the client wrapping moving to ModelManager. Model suite, offline: 19 failures to 0, apart from the bug above.

## [0.8.10.0] - 2026-10-14

### Added
- **The `git` tool can amend the top commit and force-push, and asks you every time.** Until now a stacked-change hand-off ended with "my git tool can't --amend or force-push" and two commands for you to type. `commit` now takes `amend: true`: it replaces HEAD with HEAD plus what's staged (or exactly `paths`), and keeps HEAD's message if none is given. The approval names the replaced commit and warns when it's already on a remote branch. `push` now takes `force: true`, which is `--force-with-lease=refs/heads/:` on the remote-tracking sha shown in the approval and never a bare `--force`. The approval lists every remote commit that would be dropped, a remote that moved since your last fetch is refused by git with a hint to `fetch`, and forcing the remote's default branch is refused outright. Both run under their own consent ops, `commit_amend` and `push_force`, declared in the new `consent_human_only_ops`. For those ops the gate always asks: a `permissions.json` `always`, an autonomous project, or a conversation or session grant doesn't skip the prompt, and the panel offers "once" only. Approving `commit` or `push` therefore never approves a history rewrite. `git_ops.truthy` reads the flag the same way for dispatch and for the consent op, so `amend: "false"` is a plain commit in both places (plain `bool("false")` would ask about a commit and then amend). (`app/utils/git_ops.py`, `app/utils/consent_gate.py`, `app/mcp/tools/git_tool.py`, `app/utils/git_grant_hazards.py`, `design/git-tool.md`, `Docs/Capabilities.md`; tests `tests/test_git_ops_amend_force.py` (new), `tests/test_git_ops_fixup.py`)
- **The Conversation Info dialog's Cost row lists the spend per model.** Each ledger row already records the model that answered that call and is priced at that model's rate, so a conversation that switched models mid-way was summed correctly — but only the total was shown, beside no model at all, which read as "priced at the current model". The row now shows one line per model (`global.anthropic.claude-opus-5-5 · $48.07 · 288 calls`, …) under the total. The conversation rollup is grouped by model instead of status; the estimate/actual split the row needs comes from `totals`, so nothing was lost. (`frontend/src/api/usageApi.ts` — `fetchConversationCost`, new `modelGroupLabel`; `frontend/src/components/ConversationInfoModal.tsx`; test `frontend/src/api/__tests__/usageApi.test.ts`)
- **Task cards can be exported to a file and imported into another deck.** The deck has **Export** on each card's `⋯` menu and in the editor toolbar, **Export all** and **Import** beside New/Refresh; `GET /task-cards/export[?ids=…]`, `GET /task-cards/{id}/export` and `POST /task-cards/import` back them. The file (`.ziya-task-cards.json`) is the card's *definition* only: `id`, run history, `version`, `draft` and `source` are stripped, as are every default-valued and null field, so a 4-block card is a few hundred bytes of readable tree rather than 8 KB with ~190 `null`s and a task block claiming `schedule_enabled: true`. An `about` list in the envelope explains the block vocabulary, `{{sibling("ID")}}` and what `scope` means, for a reader (or another tasking agent) with no Ziya knowledge. Import remints every block id and rewrites `sibling()` references to follow — the same reason Duplicate remints: approvals key on block id, so a signature from another machine must not authorize anything here, and an imported escalating card shows **Unsigned** until signed in this deck. An import is all-or-nothing; a name collision (a `call` by name resolves the first match) and launch-validator findings are reported as warnings, not refusals. A bare card object or a list of cards imports too. Also carries the card's `requires` block through create, which `TaskCardCreate` previously dropped. (`app/storage/task_cards.py`, `app/api/task_cards.py`, `app/models/task_card.py`, `frontend/src/services/taskCardApi.ts`, `frontend/src/components/TaskCard/TaskCardsLibrary.tsx`; tests `tests/test_task_card_export_import.py` (new, 32))
- **`git` tool `fixup` op: fix a commit in the middle of the stack** (`app/utils/git_ops.py` (`plan_fixup`, `op_fixup`; `_commit_files` / `_record_commit` factored out of `commit`), `app/mcp/tools/git_tool.py` (`target` argument), `frontend/src/components/ConsentPreflight.tsx` (summary sentence); tests `tests/test_git_ops_fixup.py` (new, 15), `tests/test_git_tool_schema_parity.py` (+1), `__tests__/ConsentPreflight.test.tsx` (+1); `Docs/Capabilities.md`, `design/git-tool.md`). The tool could only commit on top of HEAD, so a fix that belonged in an earlier commit had no route through it. `git(op="fixup", target=)` commits the staged changes (or exactly `paths`) as `fixup! ` and returns `fold_in`, the `git rebase -i --autosquash` command that folds it into the target. The tool never runs the rebase, so it still never rewrites history. The approval shows the target, the files and the commits after it, and warns when the target is already on a remote branch or a merge commit is in the way. A target that isn't an ancestor of HEAD is refused.
- **Tool-consent prompts render the plan, not JSON** (`frontend/src/components/ConsentPreflight.tsx` (new), `consent-preflight.css` (new), `ConsentPanel.tsx`; standard recorded in `Docs/BrandGuide.md` "Consent & Approval Panels"; tests `__tests__/ConsentPreflight.test.tsx` (new, 4), `__tests__/ConsentPanel.test.tsx` (+1)). The "Allow git push?" panel dumped the preflight as `JSON.stringify` in an amber box borrowed from the task-card Ask panel. It now shows a one-sentence summary for every `git_ops` plan (push/commit/add/unstage/stash/fetch/switch/checkout/clone), a labelled detail grid (humanized keys, SHAs highlighted, booleans as words, `{path, change}` rows readable), the raw plan behind a toggle, and a footer split into Approve+scope and reason+Reject. The chrome is teal on both themes with the question in a header band; the amber stays with task-card Ask panels, where it means *awaiting_input*.
- **The block editor can mark a block optional** (`frontend/src/components/TaskCard/RequiredToggle.tsx` (new), mounted in `TaskBlockEditor`, `CallBlockEditor`, `ParallelBlockEditor`, `RepeatBlockEditor`, `UntilBlockEditor`; `runMapModel.ts` (`blockConfigLines` shows `Required: no`); test `TaskCard/__tests__/requiredToggle.test.tsx` (new, 8)). `Block.required` existed on the model and in the executor but no editor surface could set it, so `required: false` was reachable only by hand-editing a card's JSON. Every non-root block header now carries a `required` checkbox; unchecking writes `required: false` (shown amber as "optional"), re-checking removes the key so a saved card stays minimal. The root has no toggle — the field is ignored there.
- **Pre-launch warning for a required Task under `on_failure: stop` with no output contract** (`app/utils/task_card_validation.py` (`_has_effective_contract`, `_walk` `parent_stops`); tests `tests/test_task_card_validation.py::TestRequiredUnderStopWithoutContract` (7, the positive case fails on the old validator)). Such a task's only verdict is the model's self-assessment, and the executor fails it on `partial` alone — which under `stop` halts every later sibling (a nightly card lost its second half to one "partial: lane 1B skipped"). The validator now warns, naming both exits: an `output_contract` (a satisfied required contract overrides `partial`) or `required: false`. A State's `output_contract_defaults` in scope silences it; children of a Parallel are not warned, since `_execute_parallel` never reads `on_failure`.
- **`scope.cwd` is documented, settable on every block layer, and checked before launch** (`app/data/built_in_skills.py` (`task_cards` grammar — Scope section), `app/utils/task_card_validation.py` (`_check_cwd`), `frontend/src/components/TaskCard/BlockScopeButton.tsx` (working-directory row), `runMapModel.ts` (`blockConfigLines` Scope line); tests `tests/test_task_card_validation.py::TestCwdConfinedToProject` (10), `TaskCard/__tests__/scopeCwdAllLayers.test.tsx` (new, 3)). `merge_scopes` already carried a container's `cwd` down to its leaves (innermost wins, like `model_tier`) and the executor already confined it to the project root — but the grammar never mentioned `cwd` and said scope "does NOT cascade", only the Task editor could set it, and an escaping or nonexistent `cwd` was discovered at run time as a silent fall-back to the project root. Models may now put `"scope": {"cwd": "services/api"}` on a Group, Parallel, Repeat, Until, Schedule or the card root. The validator errors on a `cwd` that escapes the root (`../x`, or an absolute path elsewhere) and warns on one that does not exist yet; container editors show and set it; the run map's Configuration names it. Not a privilege — no signature involved.
- **The focused-block panel says why a block failed** (`frontend/src/components/TaskCard/failureReason.ts` (new: `failureReasonOf`), `BlockDetailPanel.tsx`, `frontend/src/types/task_card.ts` (`Artifact.self_assessment`); tests `TaskCard/__tests__/failureReason.test.ts` (new, 4), `detailFailureReason.test.tsx` (new, 3)). A failed block or iteration showed `failed` as a status word and a summary that often read like success; the rationale lived in the un-rendered `self_assessment` field or deep in the decisions list. The panel now opens the output with a "Why it failed" box carrying the same string `task_finished.failure_reason` and the `task_run_read` tool produce — `self_assessment objective_met=partial: lane 1B skipped`, `empty_summary: no prose`, `output_contract: …` — derived from the persisted artifact so a run opened days later reads the same as one watched live.
- **Shell and shadow-terminal results render as one terminal pane** (`frontend/src/components/TerminalPane.tsx` (new), `frontend/src/utils/terminalPane.ts` (new), `MarkdownRenderer.tsx` (`ToolBlock` dispatch); tests `utils/__tests__/terminalPane.test.ts` (new, 24), `components/__tests__/terminalPane.test.tsx` (new, 8, including the ToolBlock seam)). A `run_shell_command` result was a raw `$ cmd` transcript in the generic tool chrome, and a `shadow_send` / `shadow_wait` / `shadow_read` result was a JSON tree whose transcript sat in one field among `ok`, `idle`, `next_seq` and the lease record. Both now share one shape: a header with a `REMOTE` badge (or a `LOCAL` one, shown only while a shadow session is attached to the conversation or the conversation contains a shadow tool call — otherwise every command is local and the tag distinguishes nothing; `frontend/src/utils/shadowPresence.ts` (new), test `utils/__tests__/shadowPresence.test.ts` (new, 3)), the working directory, the command, and status chips (`✓ exit 0` / `✗ exit N` / `⏱ still running` / line count and elapsed), over the transcript. The indicator colour tells them apart — teal for a command the server ran, fuchsia for one driven through a `ziya shadow` session (not amber or red, which already mean warning and error). Remote panes carry a seq gutter (the number `shadow_read(from_seq=…)` takes) and fold the lease overlay, the echoed command line and the shell prompt into a "N journal events hidden" row. The working directory is read from the `cd X &&` prefix or, for remote results, from the prompt line when it matches a known shape (`(date)  [~/path]`, `user@host:~/path$`, `[user@host ~/path]$`, `~/path ❯`); the `` in the first of those also supplies the exit status when the journal reports none. Long output is clipped to 8 rows with an expand control. Blocked commands and failed shadow results keep the generic block.
- **A file-operation block's file can be dragged into other applications** (`frontend/src/utils/fileDrag.ts` (new: `attachFileDrag`, `textToDataUrl`), `frontend/src/components/FileDragChip.tsx` (new, `filePathFromHeader`), `frontend/src/components/MarkdownRenderer.tsx` (chip in the `GenericToolBlock` header for `file read:` / `file write:` blocks), `app/api/drag_helper.py` (`GET /api/v1/drag/file`), `app/utils/drag_file_source.py` (new: `resolve_drag_file`, confined like `file_read`, 8 MB cap), `app/utils/drag_pasteboard_helper.py` (generic `` carrier beside the PNG one: `extract_drag_payload`, `dest_path_for`, `safe_filename`; the helper keeps the file's own name in a per-drag subdirectory, and `cleanup_stale` sweeps those too); tests `tests/test_drag_file_source.py` (new, 8), `tests/test_drag_pasteboard_helper.py` (+7), `frontend/src/utils/__tests__/fileDrag.test.ts` (new, 4), `frontend/src/components/__tests__/fileDragChip.test.tsx` (new, 2)). The header of a `file_write` / `file_read` result names a file but holds no bytes (a patch-mode write carries one hunk, a read may be a window, history drops tool inputs), so a `⠿ name.md` chip in that header fetches the file from the server on press and publishes it at dragstart as a real named file (`DownloadURL` + File item), its text (`text/plain`) and the helper carrier (`text/html`). Finder / Explorer / browser drop zones get the file; on macOS with the native helper, Slack desktop does too, under its own name. The chip is a separate element so the header text beside it stays selectable.
- **Diagrams and tables dragged out of the chat drop into Slack desktop (macOS)** (`app/utils/drag_pasteboard_helper.py` (new: `plan_rewrite`, `extract_png_data_url`, pyobjc poll loop `main`, `HelperSupervisor`), `app/api/drag_helper.py` (new: `POST /api/v1/drag/arm`, `GET /api/v1/drag/status`), `app/server.py` (spawn on startup, stop on shutdown), `app/utils/install_extras.py` (`--drag`; included by `--all` and the no-target default on macOS via `default_python_extras`, skipped when `AppKit` is already importable so the default run never needs the package index just to re-check it — `python_extra_installed`; tests `tests/test_install_extras_data.py` (+4)), `pyproject.toml` (`ziya[drag]` = `pyobjc-framework-Cocoa`), `frontend/src/utils/nativeDragArm.ts` (new), `frontend/src/utils/diagramDrag.ts` / `tableDrag.ts` (PNG carrier comment in `text/html`; prepare + arm on `pointerdown`); tests `tests/test_drag_pasteboard_helper.py` (new, 13), `frontend/src/utils/__tests__/nativeDragArm.test.ts` (new, 5), `diagramDrag.test.ts` (+1), `tableDrag.test.ts` (+1); `Docs/EnvironmentVariables.md` (`ZIYA_NATIVE_DRAG`)). A web page cannot put a file on a drag, and Slack desktop — an Electron receiver — accepts a drop only when the drag pasteboard carries a real `public.file-url`; it ignores Chromium's file promise, inline PNG bytes and data URLs, and strips every filename when it sees the `org.chromium.chromium-renderer-initiated-drag` marker. A small helper process (spawned only on macOS with pyobjc installed via `ziya-install-extras --drag`; `ZIYA_NATIVE_DRAG=0` disables) watches the system drag pasteboard and, for a drag whose renderer origin is this Ziya page, re-declares the item without the Chromium markers and with a `public.file-url` to a PNG it writes under `~/.ziya/tmp/drag/` (stale files are swept). The PNG is read off the pasteboard itself: the frontend prefixes the drag's `text/html` with an `` comment (Chromium publishes it as `public.html`), which the helper strips again so rich-text targets still receive the plain `` or ``. The frontend arms the helper with a throttled fire-and-forget POST on `pointerdown`, which also prepares the PNG immediately — fixing a drag from a cold diagram or table shipping the SVG fallback because the hover-delayed rasterization had not finished. Every other drag on the machine is untouched; the route was confirmed with `scripts/drag_pasteboard_probe.swift` / `drag_pasteboard_inject.swift`.
- **Clicking the chart handle copies it as an image** (`frontend/src/utils/diagramDrag.ts` (`copyImageToClipboard`, `dataUrlToBytes`, `copySource`/`onCopied` options), `frontend/src/utils/tableDrag.ts` (same options), `frontend/src/components/D3Renderer.tsx`; tests in `diagramDrag.test.ts` (+3), `tableDrag.test.ts` (+1)). The hover handle on pointer-interactive charts (Plotly, Vega, Joint, DrawIO, force-directed) is also a copy button: click writes the PNG as an `image/png` `ClipboardItem` (handed a `Promise` so the write stays inside the click gesture while rasterization finishes) and a toast confirms. Static diagrams and tables have no handle at all — the whole diagram, or any press on a table beside its text, is the drag source — so nothing sits over content. The handle exists only where a plugin owns the pointer and a drag on the chart itself must keep panning/zooming it. Also fixes the table drag always shipping the SVG fallback: arming `table.draggable` at `pointerdown` changed `outerHTML` and invalidated the prepared-PNG fingerprint, so the fingerprint now uses `innerHTML` and the exported markup drops the `draggable` attribute.
- **Markdown tables can be dragged into other applications** (`frontend/src/utils/tableDrag.ts` (new), `frontend/src/components/DraggableTable.tsx` (new), `frontend/src/components/MarkdownRenderer.tsx` (table case wrapped); tests `frontend/src/utils/__tests__/tableDrag.test.ts` (new, 7) and `frontend/src/components/__tests__/draggableTable.test.tsx` (new, 1)). A rendered table can be dragged from any press that lands beside its text — cell padding, gridlines, empty cells — decided per gesture at `pointerdown` by hit-testing the caret position against the text node's line boxes (`isPointOverText`), so a press on the words still selects text; a hover-revealed handle (top-right) remains as the discoverable fallback. The table travels in three forms at once: a PNG of the table as rendered via Chromium's `DownloadURL` (Slack, Finder, Explorer receive an image), the `
` itself with computed styles inlined as `text/html` (Docs, Word, Confluence and Sheets paste a real table), and the markdown source as `text/plain`. The PNG is made by wrapping the styled table in an SVG `foreignObject` and painting it through the diagram rasterizer; a drag that starts before it is ready carries the SVG. Also hardens `d3RendererVizCleanup.test.tsx` against CRA's `resetMocks`, which was silently wiping its `newPlot` implementation. - **Rendered diagrams can be dragged into other applications as images** (`frontend/src/utils/diagramDrag.ts` (new), `frontend/src/components/D3Renderer.tsx` (drag-out effect on the plugin container), `frontend/src/types/d3.ts` (`D3RenderPlugin.pointerInteractive`), opted out in the Plotly, Vega, Vega-Lite, force-directed, Joint and DrawIO plugins; tests `frontend/src/utils/__tests__/diagramDrag.test.ts` (new, 10) and `frontend/src/components/__tests__/d3RendererDiagramDrag.test.tsx` (new, 2; the packet case fails on the old renderer, whose container was never a drag source)). Dragging a rendered Mermaid, Graphviz, packet, LaTeX or other static diagram did nothing: browsers only put image bytes on the drag pasteboard for a native `` drag, and an inline `` carries nothing. The plugin container is now a drag source that publishes a PNG rasterization (opaque background taken from the surrounding theme, 2x on retina) through Chromium's `DownloadURL` drag type — the same file-promise path an `` drag uses — so Slack, Finder/Explorer and documents receive a real `.png` that renders inline rather than an attachment, plus a `text/html` `` for rich-text web targets. The PNG is prepared when the pointer rests on the diagram and cached against the SVG's content; a drag that starts before it is ready carries the SVG instead. Plugins that own pointer gestures (zoom, brush, pan, node drag) set `pointerInteractive`; for those the renderer shows a small hover-revealed handle at the diagram's bottom-left that is the drag source instead, so dragging the chart itself still pans/zooms. Plotly supplies its own PNG via `Plotly.toImage` (`__vizExportPng` on its render root) because its SVG layers omit WebGL traces. Also fixes a pre-existing test-environment gap: `frontend/craco.config.js` now maps `d3` to its UMD bundle under jest, so any test that drives `D3Renderer` through `import('d3')` (including the existing `d3RendererVizCleanup` test) runs instead of failing on d3's ESM entry. - **The Schedule editor shows what the scheduler did with the card** (`app/api/task_cards.py` (`get_schedule_state` adds `last_run`), `app/utils/task_run_read.py` (`last_run_summary`), `frontend/src/components/TaskCard/ScheduleHistory.tsx` (new: `ScheduleHistory`, `ScheduleCardContext`), `scheduleSummary.ts` (new: `summarizeScheduleState`, `formatRelative`), `ScheduleBlockEditor.tsx`, `TaskCardEditor.tsx` (provides the context), `frontend/src/services/taskCardApi.ts` (`scheduleState`, `ScheduleStateRecord`), `task-card-editor.css`; tests `tests/test_task_cards_schedule_api.py::test_enriches_with_last_run_outcome_and_skips`, `TaskCard/__tests__/scheduleHistory.test.ts` (new, 6)). A skipped slot and a failed last fire were recorded in `schedule_state.json` and the server log but on no screen; a skipped 03:05 night was discovered at 08:00 by opening the run. The Schedule block now carries a status line — "fired 47 times · last 22h ago: failed — self_assessment objective_met=partial: lane 1B skipped · skipped 1 slot (6h ago: run abcdef12 still live) · next in 2h" — refreshed every 30 s, amber with a ⚠ when a skip, a queued slot, or a failed/held last run needs attention. The endpoint resolves the newest still-existing run in `run_ids` to `{status, failed, failure_reason, held_reason}` using the same rule `task_run_read` applies, so a deleted run is skipped rather than reported. - **`task_run_read` tool: a run's outcome is readable without the browser** (`app/mcp/tools/task_run_read.py` (new), `app/utils/task_run_read.py` (new: `summarize_run`, `list_runs`, `read_journal_events`, `failure_reason_of`, `resolve_card_id`), `app/mcp/builtin_tools.py` (`task_cards` category), `app/mcp/tools/task_card_launch.py` (description no longer points at "task-run views" that no tool exposed); tests `tests/test_task_run_read.py` (new, 6), `tests/test_task_run_read_tool.py` (new, 3)). Run records are encrypted at rest and the live journal was read only by the UI, so a model — or an operator working through one — diagnosing a failed scheduled run had to reconstruct the verdict from text deltas in `events.jsonl`. The tool lists a project's or a card's runs newest first (card by id or name), reads one run (or `run_id='latest'` for a card) and returns bounded, payload-free facts: status and hold reason, the card name/version from the launch snapshot and whether it was a scheduled fire, every block's status with its `failure_signature` / `self_assessment` / one-line `failure_reason`, a `failures` list naming which block failed and why, an index of emitted outputs, the verdict-bearing journal events (`task_finished`, holds, asks — text and tool traffic are counted, never returned), and the card's schedule-state with fires and skips. `block_id` drills into one block's full decisions and iteration summaries. `failure_reason_of` applies the executor's rule to the stored artifact, so runs whose journal predates the `failure_reason` field get the same answer. - **A container's child can be marked `required: false`** (`app/models/task_card.py` (`Block.required`), `app/agents/block_executor.py` (`_execute_parallel`, `_execute_sequence`), `app/data/built_in_skills.py` (`task_cards` grammar), `frontend/src/types/task_card.ts`; tests `tests/test_block_required_false.py` (new, 9; the positive cases fail on the old executor, which counted every child)). A Parallel reported failed whenever any child failed, and under `Group(stop)` that halted the run, so a fan-out of `{1A baseline, 1B known-issues}` had no way to say that 1A must pass and 1B may not. An optional child's failure now neither trips an enclosing `on_failure: "stop"`, nor makes a Parallel report failed, nor — as the last child of a sequence — fails the sequence's own artifact; a child that raises is treated the same way. It is recorded, not hidden: the container's `decisions` carry "optional child failed … (required=false)" with the child's signature, the child's own stage evidence still reads failed, and the next sibling still receives the failed artifact as `{{previous_sibling}}`. Default `true`, so every existing card is unchanged. Also corrects the grammar text added earlier in this release, which said `on_failure` on a Parallel governs sibling cancellation: `_execute_parallel` does not read it at all. - **A satisfied output contract outranks a `partial` self-assessment** (`app/agents/task_executor.py` (verdict/contract reconciliation after the contract check), `app/utils/completion_check.py` (`is_failure` docstring), `app/data/built_in_skills.py` (`task_cards` grammar); tests `tests/test_output_contract_overrides_partial.py` (new, 6; the positive case fails on the old executor, whose contract check could only add failure and never clear it)). A task that emitted every `required` entry of its `output_contract`, each validating against its pinned schema, and then closed with `objective_met="partial"` was recorded failed — and under `Group(stop)` halted the run — on the strength of its own self-report. The contract tests the thing the verdict exists to catch ("did this task abandon its goal mid-run?") directly, so when a required contract is fully satisfied the task is now recorded ok and the verdict kept as a warning: in `decisions` with the model's rationale, and as `self_assessment` on the artifact and the `task_finished` event. Unchanged: a task with no contract or an all-optional contract (the verdict is the only evidence), a `false` verdict (a model that says false with every output present is contradicting itself), an unmet contract, and the empty/truncated-stream failures, which stand even with outputs present. - **A Schedule says what a due slot does while a run of its card is still live** (`app/models/task_card.py` (`Block.schedule_concurrency`), `app/agents/task_scheduler.py` (`_tick_card`, `_concurrency_gate`, `_live_runs_for_card`; the fire pass is extracted from the loop body so the decision is testable), `app/data/built_in_skills.py` (`task_cards` grammar), `frontend/src/types/task_card.ts`, `frontend/src/components/TaskCard/ScheduleBlockEditor.tsx` ("if still running" selector); tests `tests/test_task_scheduler.py` (7 added — the default-skip case fails on the old scheduler, which fired). The scheduler had no notion of overlap: a slot fired while a previous fire, or a hand-launched run of the same card, was still running the same workspace, and a laptop waking at 09:00 fired the missed 03:00 catch-up slot on top of a run started by hand at 08:00. `schedule_concurrency` is `skip` (default: the slot is dropped, `next_fire_at` advances, and the skip is recorded in the card's schedule-state as `skips[]` / `skips_so_far` / `last_skip_at` with the live run's id, so GET …/schedule-state shows "skipped: run still live" instead of silence), `queue` (the slot is held with `deferred_since` and fires on the first tick after the live run ends, bypassing the catch-up gate since a deferral is a decision to run late, not a missed slot), or `parallel` (the previous behaviour). The gate runs before the catch-up gate, so catch-up fires are subject to it; only `LIVE_RUN_STATUSES` count, so a failed, held or partial predecessor does not block. - **`task_finished` carries the verdict, not just `ok`** (`app/agents/task_executor.py` (`_failure_reason`); tests `tests/test_task_executor_finished_verdict.py` (new, 4)). The event now has `self_assessment` (the parsed `objective_met`/`rationale`, or null), `failure_signature` (`self_assessment_partial`, `empty_summary`, `truncated_summary`, `output_contract:`) and a one-line `failure_reason`. Run records are encrypted at rest and events.jsonl was the readable trace, so an operator diagnosing a failed night had to reassemble the model's closing tag from text deltas. - **The task-card grammar says how a Task is judged** (`app/data/built_in_skills.py`, `task_cards` skill — also what `task_card_stage` returns as the grammar). States in so many words that `objective_met="partial"` records the task as failed, lists the other three executor-imposed failures (empty summary, truncated summary, missing contracted output), and that a Parallel reports failed to its parent whenever any child failed regardless of its own `on_failure`, which only governs sibling cancellation — so `Parallel(continue)` inside `Group(stop)` halts the Group. - **A staged task card can be inspected and edited before it runs** (`frontend/src/components/TaskCard/TaskCardInlineTile.tsx` (`StagedCardTile`), `TaskCardsLibrary.tsx` (`handleSave`, `handleAddToDeck`), `frontend/src/hooks/useTaskBindings.ts` (new `TASK_CARD_SAVED_EVENT`), `task-card-inline-tile.css`; tests `TaskCard/__tests__/stagedTileFullInspect.test.ts` (4 of its 5 cases failed against the old tile; 4 added)). A card staged into a conversation — by the model's `task_card_stage`, by `/goal`, or by the deck's *Copy to conversation* — showed only its name, a collapsed Instructions box holding the first task's text, and Run / Save to deck / Discard; nothing of a loop's mode, stop condition, scope or later blocks was visible, and there was no way to change any of it short of discarding. The tile now renders the same block outline the deck uses, in edit mode, beside a definition pane for the selected block (type, instructions, loop mode and count, until-condition, state variables, on-failure, scope), and has an **Edit** button that opens the card in the deck editor via the same backlink the launched tile uses. The deck announces a save (plain Save and *Add to deck*) with the card id, and a staged tile showing that card refetches it and re-checks its signature status, so the edit appears in the tile and a card just added to the deck stops offering *Save to deck*. The outline stylesheet is loaded by the tile itself, since previously it only arrived once the deck had been opened. The launched tile is unchanged on purpose: it displays `run.card_snapshot`, so a later edit does not rewrite what a finished run is shown to have executed. - **Tool results are previewed by shape, not by their first 200 bytes** (`frontend/src/utils/toolDigest.ts` (new: `digestToolResult`, `formatBytes`), `frontend/src/components/ToolDigestView.tsx` (new: `DigestPreview`, `ToolResultViewer`, `chooseBody`), `frontend/src/components/MarkdownRenderer.tsx` (`GenericToolBlock` collapsed and expanded bodies, `DIGEST_EXEMPT_TOOLS`); tests `utils/__tests__/toolDigest.test.ts` (new, 17), `components/__tests__/toolDigestView.test.tsx` (new, 12)). A collapsed tool block showed the first line of the payload, which for pretty-printed JSON was a lone `{`, for compact JSON a run of keys, for a shell transcript the command echoed back, and for a result like `{"message": "...\n..."}` a sentence trapped in escaped JSON. The block now derives a digest from the payload's shape with no per-tool knowledge: a wrapper with `total` and an array becomes "225 issues matched, showing 100 (NETREL-600 → NETREL-699)" with status-histogram and distinct-assignee chips; a single object becomes its name plus a key/value grid of scalar fields; an object whose payload is one string is unwrapped into prose, with the URL as a link button and any failure line in an amber strip; a shell transcript previews its output lines and a non-zero `[Exit code: N]` as a chip, never the command; truncated JSON reports the keys and total it could scan. The header byline reads "102 KB · JSON" instead of a pretty-print line count. Expanded, a collection opens on a Table tab (identifier column first, hidden columns and rows beyond 50 revealed on request, copy as CSV), a record on a collapsible Tree, a message as prose, with Raw always one tab away and the digest headline pinned above. Diffs, memory cards, hierarchical search results and the file/AST/memory tools keep their existing bodies. A payload the MCP server cut off mid-object (Jira search pages near 100 KB) is closed after its last complete element (`salvageTruncatedJson`), so it still digests as "225 issues matched, showing 57" with an *output truncated* chip and a Table, instead of a list of the keys it saw. A message that is a list of entries (memory search's `[id] (layer) …` paragraphs) reads as "N entries" with the first entries previewed, and only a message's opening lines can raise the amber warning, so a memory containing "cannot" no longer looks like a failure. A diff (file write) headlines its file and `+N −M` and previews changed lines rather than its `---`/`+++`/`@@` headers; a file or markdown body headlines its first line with words, without `/**` or `#`. - **Skills in grouping folders are discovered** (`app/services/skill_discovery.py` (`iter_skill_dirs`, `MAX_SKILL_DEPTH`; all three scanners use it), `app/services/skill_sources.py` (`LocalFolderSource._dirs`, `_dir_for`), `app/utils/skill_resources.py` (`readable_skill_dirs`); test `tests/test_nested_skill_discovery.py` (new, 11); `Docs/Skills.md`). Every scanner looked only at the immediate children of a skills root, so `.agents/skills/frontend/react-patterns/SKILL.md` was skipped with no warning (only name problems log). A subdirectory with no `SKILL.md` of its own is now a grouping folder and is searched further, to three levels below the root; a directory that has a `SKILL.md` is a skill and is never searched inside. The agentskills.io spec constrains only the skill directory (`name` must match the parent directory of `SKILL.md`, which stays the leaf), so this is compliant; the Vercel `skills` CLI added the same bounded recursion behind `--full-depth`. Skill ids are keyed on root and name, not path, so moving a skill into a folder keeps its id. The read-tool allowlist resolves symlinked skills at any depth, and a local or git skill source lists and installs grouped skills by leaf name. - **Managed job runner: start a long command, wait on it by condition, kill its whole process tree, and record what it produced** (`app/jobs/wrap.py`, `app/jobs/policy.py`, `app/jobs/manager.py` (new), `app/utils/journal_wait.py` (new; the `shadow_wait` condition matcher lifted out of `app/shadow/sock_server.py` (`_op_wait`), which now calls it), `app/mcp/tools/job_tools.py` (new: `job_start` / `job_wait` / `job_status` / `job_read` / `job_kill`), `app/mcp/builtin_tools.py` (`jobs` category), `app/mcp_servers/shell_server.py` (`ShellServer(env=…)`; timeout message points at `job_start`), `app/agents/task_executor.py` and `app/api/task_cards.py` (kill owned jobs at block end / run end, not on hold), `pyproject.toml` (`ziya-jobwrap`); tests `tests/test_job_runner.py` (new, 27), `tests/test_job_runner_task_ownership.py` (new); `Docs/FeatureInventory.md`; design `design/job-runner.md`). The 2026-10-02 network scan needed five attempts to start one background query job: `nohup` and `pkill` are off the allowlist, the write checker (rightly) refuses an opaque `subprocess.Popen` script, the `mkdir` for the output directory was part of a refused compound command so the job failed at its last write, and the only way to wait was `sleep 75; cat log`. None of that is a policy bug; what was missing was a supported way to background. A job is admitted by an in-process `ShellServer` built over the exact env the MCP manager hands the shell subprocess, so the allowlist, interpreters, write policy, YOLO and the verify-or-clamp gate apply identically and the floor cannot be lowered from here; background operators are refused on top; declared outputs must be writable (and never server-only); and a refusal at any step creates nothing. The job runs under a stdlib wrapper in its own session with a credential-stripped env, journals to `.ziya/jobs//` (a `server_only_paths` entry), survives a server restart (pid + start-time guarded re-attach; a `-9`'d wrapper reads `lost`), is TERM'd then KILL'd as a group at `max_runtime_s` or on `job_kill`, and on exit records a ledger `command` observation with exit code, duration and the SHA-256 of each declared output (`outputs_missing` for ones never written). `job_start(wait_s=…)` returns a short job's result in one call, and a first `job_wait` with no `from_seq` replays the whole job journal rather than starting at the tail (a job's journal is its own, unlike a shadow session's). `keep_awake=true` (`app/jobs/inhibit.py`, new) holds `caffeinate -i -w ` / `systemd-inhibit` for the job's lifetime — spawned by the server with a fixed argv, never adjudicated as a job command — and reports `held` / `unavailable` / `failed`. `ziya jobs list | tail | kill | gc` (`app/cli.py` (`cmd_jobs`)) reads the job directories directly so it works with no server; `gc` keeps running and ledger-cited jobs and refuses when the ledger is unreadable rather than treating "unreadable" as "uncited". Jobs re-attach at server startup (`app/server.py` lifespan). The ledger observation is `inline` (store-`attested: false`), which the docs state. Journal rotation (50 MB files, 200 MB per job) is in the wrapper. The runner is advertised where a model decides how to run a command, not only in the refusal it gets afterwards: the built-in Task Cards skill's "Long-running commands" section (`app/data/built_in_skills.py`) now steers to `job_start`/`job_wait` first (with `detach="run"`, `keep_awake`, and the refused operators named) and keeps `scope.shell_timeout_secs` as the fallback for a step that must stay synchronous; and the Session Context block every chat and task turn sees (`app/utils/session_context_prompt.py` (`_format_jobs_section`)) carries a two-line version, gated on the `jobs` builtin category being enabled. Tests add `tests/test_job_runner_cli_inhibit.py` (new, 6) and `tests/test_job_runner_guidance.py` (new, 4). - **Task Card runs now feed memory** (`app/agents/run_memory_harvest.py` (new; `harvest_run_memories`, `build_run_text`, `schedule_harvest`), `app/agents/task_scheduler.py` (scheduled fires), `app/api/task_cards.py` (`/launch` runs), `app/cli_card_runner.py` (`ziya task --card`, `/card`); tests `tests/test_run_memory_harvest.py` (new, 13); `Docs/FeatureInventory.md`, `Docs/EnvironmentVariables.md`). Until now memory extraction only ran on the web chat stream, and it dropped the task results injected into chat because they arrive as `system` messages. Research done in cards never reached memory at all. A run that finishes `done` or `partial` now has its root result, decisions, text outputs and passed stage summaries sent through a run-specific extraction prompt, which treats the report as data, keeps only established facts and drops facts about the run itself. The resulting facts go into the probationary store under stricter rules than chat extraction. Every run of one card is a single corroboration source, so a 60-way fan-out or an hourly schedule cannot promote its own finding; a chat or a different card has to agree. Proposals never fast-track on quality; the grade is kept as `run_quality`. A run never rewrites an active memory, it only bumps the corroboration of one it agrees with. A contradiction of a pending proposal is recorded as `contradicted`. Neither the activity counter nor the domain clocks advance, so frequent automated runs do not age the user's probationary memories. Opt out with the card tag `no-memory` or `ZIYA_MEMORY_HARVEST_RUNS=0`. - **Memory export: a bounded, previewed, audited slice of memory for another user** (`app/utils/memory_export.py` (new; `ExportSelection`, `export_options`, `plan_export`, `build_bundle`, `scan_memory`, `fingerprint_ids`), `app/api/memory.py` (`GET /api/v1/memory/export/options`, `POST /api/v1/memory/export/preview`, `POST /api/v1/memory/export`), `frontend/src/api/memoryApi.ts` (`exportReadiness`, `runExport`), `frontend/src/components/MemoryExportPanel.tsx` (new), `MemoryBrowser.tsx` (Export tab); tests `tests/test_memory_export.py` (new, 16), `tests/test_api_memory_export.py` (new, 6), `frontend/src/api/__tests__/memoryExportHelpers.test.ts` (new, 8); `Docs/FeatureInventory.md`). The design doc says memory is "never shared without explicit user action"; this is that action. The Export tab selects by mind-map domain (a node takes its subtree), includes or excludes projects (exclusion wins, so a memory scoped to both an included and an excluded project is held back), and filters layers and statuses. A live preview shows the exact count, a breakdown by layer, domain and project, each reason memories were held back, and a leak scan of the content that reuses the shadow redactor's credential shapes plus home-directory paths and email addresses; flagged items must be acknowledged before the button enables. Every exported memory is rebuilt from a field whitelist — conversation ids, retrieval telemetry and access dates are dropped and project paths reduced to folder names unless the user opts in — and relations are pruned to ids inside the bundle. The export request carries the preview's fingerprint of the selected id set; the server re-resolves the selection and refuses with 409 when the store no longer matches and 412 when warnings are unacknowledged, and logs `memory_exported` / `memory_export_refused` to the security audit trail. The bundle header honours anonymization too (the first test run caught raw paths in the per-project counts). - **Task cards declare the skills, tools and MCP servers they need, and are checked before they run** (`app/models/task_card.py` (`CardRequirements`, `TaskCard.requires`), `app/utils/task_card_preflight.py` (new; `collect_requirements`, `check_requirements`, `run_preflight`), `app/cli_card_runner.py` (`run_card(strict=)`), `app/cli.py` (`ziya task --strict`), `app/api/task_cards.py` (launch preflight, 412 on a strict card), `app/data/built_in_skills.py` (task_cards skill: `requires`); test `tests/test_task_card_preflight.py` (new, 8)). A card run on a host that lacks an MCP server or a skill used to find out when the executor reached the task that wanted it, as one line in that task's artifact decisions; for a batch run that is an hour in, from a confused model. A card can now carry `requires: {skills, mcp_servers, tools, strict}`, and before the first model call every requirement — plus every skill and tool named in any scope of the card — is checked against the live environment (stored skills and `.ziya/skills` discovery, loaded tools, `MCPManager.get_server_status()`). Missing, unconfigured, disconnected and quarantined items are reported with the block that needs them. By default the card still runs (a warning); `requires.strict` on the card, or `ziya task --strict`, refuses — exit 3 from the CLI, 412 from the launch endpoint. Floor tools (`emit_artifact`, `render_diagram`, beads) are never reported missing. - **A headless run's implicit memory store is removed at exit** (`app/config/environment.py` (`_setup_memory_store`); test `tests/test_memory_reference_mode.py`). `--memory-bundle` without `--memory-dir` created a `ziya-memory-*` temp directory per run and never removed it. It is now registered with `atexit`; pass `--memory-dir` to keep a store across runs. - **Memory import, and headless runs against a reference memory set** (`app/utils/memory_import.py` (new; `ImportOptions`, `load_bundle`, `plan_import`, `apply_import`, `seed_store`), `app/utils/paths.py` (`get_memory_dir`, `memory_writes_allowed`), `app/api/memory.py` (`POST /api/v1/memory/import/preview`, `POST /api/v1/memory/import`), `app/config/common_args.py` + `app/config/environment.py` (`--memory-dir`, `--memory-bundle`, `--memory-readonly`), writers in `app/storage/memory.py`, `app/storage/proposals.py`, `app/services/embedding_service.py`, `app/utils/memory_clock.py`, `app/memory/feedback.py`, `app/memory/organize_history.py`, `app/memory/extractor.py`; `frontend/src/components/MemoryImportPanel.tsx` (new), `MemoryBrowser.tsx` (Export / Import tab), `memoryApi.ts` (`importReadiness`, `runImport`); tests `tests/test_memory_import.py` (new, 12), `tests/test_api_memory_import.py` (new, 3), `tests/test_memory_reference_mode.py` (new, 3), `frontend/src/api/__tests__/memoryImportHelpers.test.ts` (new, 6); `Docs/FeatureInventory.md`). Two ways to use another user's bundle. Interactively: the Import side of the Memory Browser's transfer tab classifies every memory in the bundle against your store — new, already present (same id and content), duplicate content under another id (skipped, with references redirected to yours), or an id collision resolved by *keep both* (incoming memory renamed and every reference to it rewritten), *keep mine* or *take theirs* — merges the bundle's mind-map nodes into yours (your handle wins, member lists union), and tags each imported memory `imported` and `from: