diff --git a/.github/workflows/ohos.yml b/.github/workflows/ohos.yml new file mode 100644 index 0000000000..3717de77fa --- /dev/null +++ b/.github/workflows/ohos.yml @@ -0,0 +1,71 @@ +name: OpenHarmony + +on: + push: + branches: [main, master] + paths: + - '.github/workflows/ohos.yml' + - 'Cargo.lock' + - 'Cargo.toml' + - 'crates/**' + - 'scripts/ohos/**' + - 'scripts/ohos-env.sh' + - 'scripts/release/check-ohos-deps.sh' + pull_request: + branches: [main, master] + paths: + - '.github/workflows/ohos.yml' + - 'Cargo.lock' + - 'Cargo.toml' + - 'crates/**' + - 'scripts/ohos/**' + - 'scripts/ohos-env.sh' + - 'scripts/release/check-ohos-deps.sh' + schedule: + - cron: '47 7 * * 1' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ohos-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + CARGO_INCREMENTAL: 0 + RUSTFLAGS: -Dwarnings + +jobs: + cargo-check: + name: cargo check (aarch64-unknown-linux-ohos) + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@v7 + - name: Install Rust target + uses: dtolnay/rust-toolchain@stable + with: + targets: aarch64-unknown-linux-ohos + - name: Install OpenHarmony native SDK + id: ohos-sdk + # Pin the action: it downloads a published OpenHarmony SDK and verifies + # the archive checksum. This job must never substitute host headers or + # a stub sysroot and report that as target support. + uses: openharmony-rs/setup-ohos-sdk@b312caf8a43bb836aa24c08f65f97e1f09a89cad + with: + version: '6.1' + components: native + cache: true + mirror: true + - name: Check Codewhale TUI with the real SDK/sysroot + shell: bash + env: + OHOS_NATIVE_SDK: ${{ steps.ohos-sdk.outputs.ohos_sdk_native }} + run: | + set -euo pipefail + test -x "${OHOS_NATIVE_SDK}/llvm/bin/clang" + test -d "${OHOS_NATIVE_SDK}/sysroot" + . ./scripts/ohos-env.sh + cargo check --target aarch64-unknown-linux-ohos -p codewhale-tui --locked diff --git a/crates/tui/src/sandbox/mod.rs b/crates/tui/src/sandbox/mod.rs index bb8bf9b3e9..b9071e91f6 100644 --- a/crates/tui/src/sandbox/mod.rs +++ b/crates/tui/src/sandbox/mod.rs @@ -12,6 +12,9 @@ //! - **Linux**: Uses bubblewrap only when the user opts in and `/usr/bin/bwrap` //! is executable. Landlock and seccomp helpers are not wired into child //! execution yet and therefore are not advertised. +//! - **OpenHarmony**: No local Linux sandbox is advertised. Bubblewrap, +//! Landlock, seccomp, and Linux `prctl` hardening are gated out under +//! `target_env = "ohos"`. //! - **Windows**: No OS sandbox is advertised yet. The planned first helper //! contract is process-tree containment only via a Windows Job Object; it //! must not claim filesystem, network, registry, or AppContainer isolation. diff --git a/crates/tui/src/tools/shell.rs b/crates/tui/src/tools/shell.rs index 9309e28589..0efc1b43dc 100644 --- a/crates/tui/src/tools/shell.rs +++ b/crates/tui/src/tools/shell.rs @@ -55,6 +55,32 @@ use crate::work_graph::{ use crate::worker_profile::ShellPolicy; use output::{tail_from_buffer, take_delta_from_buffer}; +fn validate_shell_working_dir(path: &Path, inherited_session_workspace: bool) -> Result<()> { + let metadata = std::fs::metadata(path).with_context(|| { + let source = if inherited_session_workspace { + "saved session workspace" + } else { + "requested working directory" + }; + format!( + "{source} is unavailable: {}. Restore or remap that directory, resume/fork the session from an existing workspace, or pass an explicit `working_dir`/`cwd` to exec_shell", + path.display() + ) + })?; + if !metadata.is_dir() { + let source = if inherited_session_workspace { + "saved session workspace" + } else { + "requested working directory" + }; + return Err(anyhow!( + "{source} is not a directory: {}. Resume/fork from an existing workspace or pass an explicit `working_dir`/`cwd`", + path.display() + )); + } + Ok(()) +} + /// Status of a shell process #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub enum ShellStatus { @@ -1267,6 +1293,7 @@ impl ShellManager { crate::shell_dispatcher::ShellDispatcher::log_exec(command); let work_dir = working_dir.map_or_else(|| self.default_workspace.clone(), PathBuf::from); + validate_shell_working_dir(&work_dir, working_dir.is_none())?; // Clamp timeout to max 10 minutes (600000ms) let timeout_ms = timeout_ms.clamp(1000, 600_000); @@ -1342,6 +1369,7 @@ impl ShellManager { crate::shell_dispatcher::ShellDispatcher::log_exec(command); let work_dir = working_dir.map_or_else(|| self.default_workspace.clone(), PathBuf::from); + validate_shell_working_dir(&work_dir, working_dir.is_none())?; let timeout_ms = timeout_ms.clamp(1000, 600_000); let policy = policy_override.unwrap_or_else(|| self.sandbox_policy.clone()); diff --git a/crates/tui/src/tools/shell/tests.rs b/crates/tui/src/tools/shell/tests.rs index 07d239805f..664de688b6 100644 --- a/crates/tui/src/tools/shell/tests.rs +++ b/crates/tui/src/tools/shell/tests.rs @@ -21,6 +21,37 @@ fn env_lock() -> &'static Mutex<()> { const BACKGROUND_COMPLETION_WAIT_MS: u64 = 30_000; +#[test] +fn deleted_saved_workspace_reports_path_and_recovery_before_spawn() { + let workspace = tempdir().expect("workspace"); + let stale = workspace.path().join("deleted-session-workspace"); + let mut manager = ShellManager::new(stale.clone()); + + let error = manager + .execute("echo should-not-run", None, 1_000, false) + .expect_err("missing saved workspace must fail before shell spawn"); + let message = error.to_string(); + assert!(message.contains("saved session workspace is unavailable")); + assert!(message.contains(&stale.display().to_string())); + assert!(message.contains("working_dir") || message.contains("cwd")); + assert!(message.contains("resume/fork")); +} + +#[test] +fn explicit_missing_working_dir_is_not_misreported_as_session_corruption() { + let workspace = tempdir().expect("workspace"); + let missing = workspace.path().join("explicit-missing"); + let mut manager = ShellManager::new(workspace.path().to_path_buf()); + + let error = manager + .execute("echo should-not-run", missing.to_str(), 1_000, false) + .expect_err("missing explicit cwd must fail before shell spawn"); + let message = error.to_string(); + assert!(message.contains("requested working directory is unavailable")); + assert!(message.contains(&missing.display().to_string())); + assert!(!message.contains("saved session workspace")); +} + #[cfg(not(target_env = "ohos"))] #[test] fn pty_exit_status_preserves_high_windows_code_losslessly() { diff --git a/crates/tui/src/tui/clipboard.rs b/crates/tui/src/tui/clipboard.rs index 68b7bbf565..e3f09d7216 100644 --- a/crates/tui/src/tui/clipboard.rs +++ b/crates/tui/src/tui/clipboard.rs @@ -6,6 +6,10 @@ //! V4 does not currently accept inline image input on its Chat Completions //! endpoint, so we materialize the bytes to disk instead of base64-embedding //! them in the request). +//! +//! OpenHarmony deliberately excludes native desktop/Wayland clipboard APIs. +//! Copy falls back to OSC 52 (or tmux `load-buffer -w`), paste arrives through +//! terminal input, and image clipboard reads are unavailable. use std::ffi::OsStr; #[cfg(any(not(test), all(test, unix)))] diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 868a670631..29a1f3a129 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -2352,7 +2352,7 @@ async fn run_event_loop( // Fire-and-forget version check — runs once per session in the // background. On success, a short status toast advertises the update // without replacing the user's configured footer/status-line chips. - let mut version_check: Option>> = + let mut version_check: Option>> = spawn_startup_version_check(config.update_config()); // Fire a one-shot initial balance fetch for DeepSeek providers @@ -2390,12 +2390,18 @@ async fn run_event_loop( if let Some(ref handle) = version_check { done = handle.is_finished(); } - if done && let Ok(Some(hint)) = version_check.take().unwrap().await { + if done && let Ok(Some(notice)) = version_check.take().unwrap().await { + // Transient toast for immediate visibility, plus a durable + // in-transcript notice so the prompt survives the toast TTL and + // stays actionable during a busy session (#3961). app.push_status_toast( - hint, + notice.toast_line(), StatusToastLevel::Info, Some(VERSION_HINT_TOAST_TTL_MS), ); + app.add_message(HistoryCell::System { + content: notice.notice_block(), + }); } if !drain_web_config_events(&mut web_config_session, app, config, &engine_handle).await { @@ -16167,7 +16173,7 @@ fn startup_version_check_source(config: &UpdateConfig) -> StartupVersionCheckSou fn spawn_startup_version_check( config: UpdateConfig, -) -> Option>> { +) -> Option>> { let source = startup_version_check_source(&config); if source == StartupVersionCheckSource::Disabled { return None; @@ -16182,7 +16188,7 @@ fn spawn_startup_version_check( async fn version_hint_from_startup_source( source: StartupVersionCheckSource, current: &str, -) -> Option { +) -> Option { match source { StartupVersionCheckSource::Disabled => None, StartupVersionCheckSource::ConfiguredUrl(url) => { @@ -16208,7 +16214,7 @@ async fn version_hint_from_startup_source( } } -async fn version_hint_from_release_mirror_env(current: &str) -> Option { +async fn version_hint_from_release_mirror_env(current: &str) -> Option { if !release_mirror_env_configured() { return None; } @@ -16228,7 +16234,7 @@ fn release_mirror_env_configured() -> bool { async fn version_hint_from_configured_update_uri( update_uri: &str, current: &str, -) -> Result> { +) -> Result> { let body = codewhale_release::fetch_release_json_async(update_uri, "configured latest release") .await?; let json: serde_json::Value = serde_json::from_str(&body).with_context(|| { @@ -16237,7 +16243,7 @@ async fn version_hint_from_configured_update_uri( Ok(version_hint_from_custom_release_json(&json, current)) } -fn version_hint_from_release_json(json: &serde_json::Value, current: &str) -> Option { +fn version_hint_from_release_json(json: &serde_json::Value, current: &str) -> Option { if !release_has_required_assets(json) { return None; } @@ -16249,7 +16255,7 @@ fn version_hint_from_release_json(json: &serde_json::Value, current: &str) -> Op fn version_hint_from_custom_release_json( json: &serde_json::Value, current: &str, -) -> Option { +) -> Option { if !release_is_publishable(json) { return None; } @@ -16260,15 +16266,47 @@ fn version_hint_from_custom_release_json( version_hint_from_latest_tag(tag, current) } -fn version_hint_from_latest_tag(tag: &str, current: &str) -> Option { +/// A newer-stable-release notice, carrying enough context to render both the +/// short transient toast and the durable in-transcript update prompt (#3961). +#[derive(Debug, Clone, PartialEq, Eq)] +struct UpdateNotice { + current: String, + latest: String, +} + +impl UpdateNotice { + /// Short line for the transient status toast (unchanged wording). + fn toast_line(&self) -> String { + format!( + "v{latest} available - run `codewhale update` and restart", + latest = self.latest + ) + } + + /// Durable, actionable notice pushed into the transcript so it survives the + /// toast TTL. Includes current/latest versions, release notes, the exact + /// update command, and restart guidance. + fn notice_block(&self) -> String { + format!( + "Update available: v{current} -> v{latest}\n\ + Release notes: https://github.com/Hmbown/CodeWhale/releases/tag/v{latest}\n\ + Run `codewhale update` (preview with `codewhale update --check`), then restart CodeWhale.", + current = self.current, + latest = self.latest + ) + } +} + +fn version_hint_from_latest_tag(tag: &str, current: &str) -> Option { let latest = tag.trim_start_matches('v'); if !is_newer_version(latest, current) { return None; } - Some(format!( - "v{latest} available - run `codewhale update` and restart, then /change for what's new" - )) + Some(UpdateNotice { + current: current.to_string(), + latest: latest.to_string(), + }) } fn release_has_required_assets(json: &serde_json::Value) -> bool { diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index f5941e20d9..6678c35bee 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -7339,31 +7339,43 @@ fn hotbar_alt_digit_requires_plain_alt_one_through_eight() { let mut app = create_test_app(); app.onboarding = OnboardingState::None; - assert_eq!( - hotbar_slot_from_key( - &app, - &KeyEvent::new( - KeyCode::Char('4'), - KeyModifiers::ALT | KeyModifiers::CONTROL - ) - ), - None - ); - assert_eq!( - hotbar_slot_from_key( - &app, - &KeyEvent::new(KeyCode::Char('4'), KeyModifiers::ALT | KeyModifiers::SUPER) - ), - None - ); - assert_eq!( - hotbar_slot_from_key(&app, &KeyEvent::new(KeyCode::Char('0'), KeyModifiers::ALT)), - None - ); - assert_eq!( - hotbar_slot_from_key(&app, &KeyEvent::new(KeyCode::Char('9'), KeyModifiers::ALT)), - None - ); + for slot in 1..=8 { + let digit = char::from_digit(slot.into(), 10).expect("Hotbar slot digit"); + assert_eq!( + hotbar_slot_from_key( + &app, + &KeyEvent::new(KeyCode::Char(digit), KeyModifiers::ALT) + ), + Some(slot), + "plain Alt-{slot} must dispatch the corresponding Hotbar slot" + ); + for modifiers in [ + KeyModifiers::NONE, + KeyModifiers::CONTROL, + KeyModifiers::SUPER, + KeyModifiers::ALT | KeyModifiers::CONTROL, + KeyModifiers::ALT | KeyModifiers::SUPER, + ] { + assert_eq!( + hotbar_slot_from_key(&app, &KeyEvent::new(KeyCode::Char(digit), modifiers)), + None, + "modifiers {modifiers:?} must not impersonate Alt-{slot}" + ); + } + } + + for key in [ + KeyCode::Char('0'), + KeyCode::Char('9'), + KeyCode::F(1), + KeyCode::F(4), + ] { + assert_eq!( + hotbar_slot_from_key(&app, &KeyEvent::new(key, KeyModifiers::ALT)), + None, + "out-of-contract key {key:?} must not dispatch a Hotbar slot" + ); + } } #[test] @@ -8867,7 +8879,7 @@ fn complete_release_json(tag: &str) -> serde_json::Value { fn version_hint_requires_complete_release_assets() { let complete = complete_release_json("v0.8.47"); let hint = version_hint_from_release_json(&complete, "0.8.46").expect("newer complete release"); - assert!(hint.contains("v0.8.47 available")); + assert!(hint.toast_line().contains("v0.8.47 available")); let mut missing_manifest = complete_release_json("v0.8.47"); missing_manifest["assets"] = serde_json::Value::Array( @@ -8955,7 +8967,43 @@ fn custom_update_uri_accepts_tag_only_release_json() { let hint = version_hint_from_custom_release_json(&json, "0.8.46") .expect("tag-only custom metadata should be enough for mirrors"); - assert!(hint.contains("v0.8.47 available")); + assert!(hint.toast_line().contains("v0.8.47 available")); +} + +#[test] +fn update_notice_block_is_persistent_and_actionable() { + let complete = complete_release_json("v0.8.47"); + let notice = version_hint_from_release_json(&complete, "0.8.46") + .expect("newer complete release yields a notice"); + + // Durable notice carries current + latest versions, release-notes link, + // the exact update command, and restart guidance (#3961 acceptance). + let block = notice.notice_block(); + assert!( + block.contains("v0.8.46"), + "shows current version: {block:?}" + ); + assert!(block.contains("v0.8.47"), "shows latest version: {block:?}"); + assert!( + block.contains("https://github.com/Hmbown/CodeWhale/releases/tag/v0.8.47"), + "includes release-notes link: {block:?}" + ); + assert!( + block.contains("codewhale update"), + "includes the update command: {block:?}" + ); + assert!( + block.contains("codewhale update --check"), + "prefers the check-first command: {block:?}" + ); + assert!( + block.to_lowercase().contains("restart"), + "includes restart guidance: {block:?}" + ); + + // A current release produces no notice at all (no toast, no transcript spam). + let current = complete_release_json("v0.8.46"); + assert!(version_hint_from_release_json(¤t, "0.8.46").is_none()); } #[test] diff --git a/docs/ACCESSIBILITY.md b/docs/ACCESSIBILITY.md index e11b48821c..9ce21955a5 100644 --- a/docs/ACCESSIBILITY.md +++ b/docs/ACCESSIBILITY.md @@ -16,7 +16,7 @@ visual motion and density for screen-reader and low-motion users. | `ocean_treatment` setting | `ombre` | Chooses the background appearance: `ombre` paints the state-reactive water column; `flat` uses the plain theme surface. Both keep the same state marks and idle ambient life; appearance is independent of motion settings. | | `status_indicator` setting | `cw` | Static typographic header mark. Set to `whale` or `dots` for the legacy animations, or `off` to hide it. | | `calm_mode` setting | `true` | Collapses tool-output details by default and trims status messages. Useful for screen readers that announce every redraw. | -| `show_thinking` setting | `true` | Set to `false` to hide model `reasoning_content` blocks entirely. | +| `show_thinking` setting | `true` | Set to `false` to hide model `reasoning_content` blocks from the TUI presentation. Canonical session/replay receipts remain unchanged. | | `show_tool_details` setting | `false` | Set to `true` to expand tool calls inline; details remain available on demand either way. | | `inline_diffs` setting | `full` | Use `summary` or `off` to reduce inline File-change density. Exact applied evidence remains available with Alt/Option+V in every mode. | diff --git a/docs/HarmonyOS.md b/docs/HarmonyOS.md index 61931e08f2..ee5a0a29b1 100644 --- a/docs/HarmonyOS.md +++ b/docs/HarmonyOS.md @@ -2,6 +2,19 @@ This page covers Codewhale on HarmonyOS PC and OpenHarmony cross-build setups. +## Support Tier + +| Target | Codewhale tier | CI coverage | Distribution | +| --- | --- | --- | --- | +| HarmonyOS PC with a glibc-compatible userspace | Tier 1 Linux ARM64 runtime | Covered by the Linux ARM64 release build | npm and release binaries | +| `aarch64-unknown-linux-ohos` (OpenHarmony) | Tier 2 cross-build target | `codewhale-tui` is checked with a real OpenHarmony native SDK/sysroot | Build from source; no prebuilt release asset | + +Tier 2 means every relevant source change is compile-checked, but maintainers do +not promise a release binary or full device-level runtime testing. The CI job +uses the published OpenHarmony 6.1 native SDK; it deliberately fails if the SDK, +Clang, or sysroot is unavailable rather than substituting host headers or a stub +that could report false success. + ## Running On HarmonyOS PC HarmonyOS PC can use the normal Linux ARM64 package when its userspace is @@ -105,3 +118,17 @@ Because `portable-pty` is intentionally absent from the OpenHarmony graph, the persistent `terminal/*` PTY tools are not registered on that target. The ordinary `exec_shell` tools remain available through their non-PTY process implementation. + +Linux-only sandbox implementations (bubblewrap, Landlock, seccomp, and `prctl` +process hardening) are compiled only for +`all(target_os = "linux", not(target_env = "ohos"))`. OpenHarmony therefore +reports no local OS sandbox instead of probing Linux kernel paths or syscalls it +does not support. External OpenSandbox execution remains separately available +when configured. + +Native desktop clipboard libraries and Wayland helpers are also excluded from +the OpenHarmony graph. Text copy degrades to the terminal-client path (OSC 52, +or tmux `load-buffer -w` when inside tmux); paste is supplied by the terminal as +normal/bracketed input. Image clipboard reads are unavailable on this target. +If the terminal cannot accept OSC 52, copy returns a clear "Clipboard +unavailable" error rather than panicking or claiming success. diff --git a/docs/evidence/hotbar-qa-matrix.md b/docs/evidence/hotbar-qa-matrix.md index 5c161f2395..edaaba2992 100644 --- a/docs/evidence/hotbar-qa-matrix.md +++ b/docs/evidence/hotbar-qa-matrix.md @@ -49,6 +49,42 @@ coverage. | Unknown action | Unknown configured action is visible and does not dispatch. | `crates/tui/src/tui/sidebar.rs::hotbar_panel_slots_handle_empty_partial_and_unknown_config`; `crates/tui/src/tui/ui.rs::dispatch_hotbar_slot` | | Approval-gated/deferred source | Source is explicitly deferred and must not register bindable actions before gates exist. | `crates/tui/src/tui/hotbar/actions.rs::source_descriptors_cover_dispatch_boundaries`; `crates/tui/src/tui/hotbar/actions.rs::deferred_sources_cannot_register_dispatchable_actions` | +## Terminal chord evidence for v0.9.2 (#3758) + +Codewhale receives terminal key events; it cannot force a terminal to forward the +macOS Option key as Meta. The source-level contract below is release-gated on +every platform. Terminal/device observations stay separate so an unrun terminal +is never presented as green. + +### Automated event matrix + +| Input/state | Required result | Regression evidence | +| --- | --- | --- | +| `Alt-1` through `Alt-8` | Dispatch the corresponding slot | `hotbar_alt_digit_fires_from_composer_and_sidebar_states` | +| Bare `1` through `8` | Insert/retain ordinary composer input | `hotbar_bare_digit_inserts_text_even_when_composer_empty` | +| `Ctrl-number`, `Super/Cmd-number`, `Alt-0`, `Alt-9`, and F-keys | Never dispatch a Hotbar slot; `F1` remains help | `hotbar_slot_from_key_accepts_only_alt_one_through_eight` and global keybinding tests | +| AltGr-style `Ctrl+Alt+number` | Never dispatch, preserving non-US input ownership | `hotbar_slot_from_key_accepts_only_alt_one_through_eight` | +| Modal, onboarding, slash/history selector, approval, picker, or decision card | Owning surface blocks Hotbar dispatch | `hotbar_digits_are_blocked_while_modal_or_onboarding_is_active`, `hotbar_alt_digit_is_blocked_while_inline_selectors_are_open`, `hotbar_alt_digit_is_blocked_while_decision_card_is_active` | +| Default-hidden/disabled Hotbar | No visible accelerator surface and no slot dispatch | config/sidebar hidden-state tests listed above | + +### Manual terminal matrix + +Record the exact app/version and the terminal key setting when a device pass is +run. A blank or `UNRUN` row is not release evidence. + +| OS | Terminal | Meta/Option setting | Alt/Option-1..8 | Bare/Cmd/F1 | Modal/default-hidden | Notes | +| --- | --- | --- | --- | --- | --- | --- | +| macOS | Terminal.app | UNRUN | UNRUN | UNRUN | UNRUN | Some Option settings emit characters rather than Meta. | +| macOS | iTerm2 | UNRUN | UNRUN | UNRUN | UNRUN | Record the Left/Right Option key mapping. | +| macOS | Ghostty | UNRUN | UNRUN | UNRUN | UNRUN | Record `macos-option-as-alt` or equivalent. | +| macOS | Kitty | UNRUN | UNRUN | UNRUN | UNRUN | Record any `macos_option_as_alt` setting. | +| Linux | terminal + layout | UNRUN | UNRUN | UNRUN | UNRUN | Include an AltGr/non-US layout pass. | +| Windows | Windows Terminal | UNRUN | UNRUN | UNRUN | UNRUN | Include the host shell and keyboard layout. | + +A terminal-specific caveat belongs in `docs/KEYBINDINGS.md` and `/hotbar help` +only after it is reproduced. The product must not advertise Cmd-number or +function-key aliases merely because a terminal can remap them. + ## Release Smoke Checklist Run before claiming Hotbar MVP readiness: diff --git a/docs/rfcs/4468-output-presentation-filters.md b/docs/rfcs/4468-output-presentation-filters.md new file mode 100644 index 0000000000..6ace027492 --- /dev/null +++ b/docs/rfcs/4468-output-presentation-filters.md @@ -0,0 +1,123 @@ +# RFC: Output presentation filters without receipt mutation + +**Issue:** #4468 + +**Status:** Accepted for the v0.9.2 product boundary + +**Date:** 2026-07-26 + +## Decision + +Codewhale will not run an arbitrary user script between a model response and +the canonical session record. Model-native assistant and thinking blocks remain +the durable audit, replay, cache-accounting, debugging, and provider-signature +source of truth. + +Output compression belongs to an explicit **presentation/export** layer. The +first supported controls are the existing safe surfaces: + +- TUI `show_thinking = false` hides thinking from the rendered transcript but + does not delete it from the canonical message/receipt path. +- `codewhale exec --output-format text|stream-json` selects a documented output + encoding. Structured output retains block identity so downstream tools can + select `thinking` or `text` without Codewhale rewriting either. +- Exports may add a future `--view canonical|response-only|thinking-only` + selector. A filtered export must label itself as a derived view and retain a + canonical session reference; it must never overwrite the session. + +This addresses the accessibility and automation need behind the proposed +CIPHER filter without turning untrusted scripts into invisible transcript +editors. + +## Why + +### Receipt fidelity + +The session is an audit record. Replacing content before persistence would make +it impossible to prove what the provider emitted, would corrupt signed +Anthropic thinking blocks, and could make usage/cost receipts disagree with the +visible record. Storing only the transformed form is rejected. Storing both +forms by default doubles sensitive data and creates ambiguous replay authority, +so it is also rejected. + +### Prompt cache and replay + +Presentation filters do not change request-side token use. In particular, +`reasoning_replay_tokens` and provider-specific signed-thinking replay must use +the canonical form. A compression claim must separately measure: + +1. terminal/export bytes; +2. local storage bytes; +3. request-side replay tokens; +4. provider cache-hit behavior. + +Only the first is affected by the accepted v0.9.2 boundary. + +### Streaming + +`HookEvent::ResponseDelta` is observer-only and arrives incrementally. A +block-level transformation would require buffering until block end, adding +latency and changing cancellation semantics. Presentation consumers may buffer +for their own output, but the engine continues to emit and persist canonical +deltas. + +### Trust and failure + +No new arbitrary command execution is added. An external consumer may read +`stream-json` and apply its own bounded transform outside Codewhale. Its failure +cannot corrupt, delay, or replace the session. The canonical record therefore +provides the fail-open source automatically. + +## Structured-output contract + +`stream-json` is the accessibility and integration surface: + +- events are JSON lines; +- response/thinking block identity remains explicit; +- tools may omit a block from their derived view, but must not describe that + view as the canonical session; +- no environment map, provider credential, hidden tool payload, or unrelated + transcript content is added for filtering; +- downstream tools should bound input, output, and processing time themselves. + +Example response-only presentation: + +```sh +codewhale exec --output-format stream-json "..." \ + | jq -r 'select(.type == "message_delta") | .text // empty' +``` + +The exact event names are versioned runtime output and callers should inspect a +fixture from their installed version rather than infer fields from this RFC. + +## Rejected alternatives + +1. **Pre-persistence output hook.** Rejected: mutates audit/replay authority. +2. **Mutate only thinking.** Rejected: signed thinking and request replay still + require fidelity. +3. **Store canonical plus transformed by default.** Rejected: duplicate + sensitive content and unclear authority. +4. **Prompt the model to abbreviate.** The reporter measured 0% adoption and it + is not a reliable mechanical contract. +5. **A separate `[hooks.output_filter]` table.** Rejected: duplicates the hook + schema while failing to solve the trust and receipt problems. + +## Future additive work + +A future derived-export API may accept a declarative, non-executable selector +and write a receipt containing the source session id, source content hash, +selector, and derived output hash. Arbitrary executable transforms remain an +external pipeline unless a later security review defines sandboxing, +disclosure, latency, and dual-form retention semantics. + +## Acceptance checks + +- Canonical session persistence and reasoning replay remain unchanged. +- `show_thinking` is documented as display-only. +- `stream-json` is documented as the safe machine-readable filter boundary. +- No hook stdout gains response-mutation authority. +- No new script, shell, credential, or network capability is introduced. + +Credit: the CIPHER measurements and the bounded stdin/stdout/fail-open proposal +came from @eugenicum in #4468. The v0.9.2 decision preserves that integration +use case while keeping Codewhale's canonical receipts trustworthy.