You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by wy...@apache.org on 2026/10/17 05:27:41 UTC

(maka) branch rust-preview updated: feat(host): capture and stream root-scoped diagnostics

This is an automated email from the ASF dual-hosted git repository.

M4n5ter pushed a commit to branch rust-preview
in repository https://gitbox.apache.org/repos/asf/maka.git


The following commit(s) were added to refs/heads/rust-preview by this push:
     new 805233a4f3 feat(host): capture and stream root-scoped diagnostics
805233a4f3 is described below

commit 805233a4f359f10f56079ed1ece8d6872af78e8c
Author: Wang <m4...@gmail.com>
AuthorDate: Sat Oct 17 13:27:24 2026 +0800

    feat(host): capture and stream root-scoped diagnostics
    
    Capture dedicated Host stderr before detached startup and expose bounded raw-byte reads and cancellable subscriptions through the CLI and explicitly granted native service. Add no-session Diagnostics access in the default product and verify the real terminal, public Client, cursor recovery, and cancellation boundaries.
    
    Generated-by: Codex
---
 packages/plugin-sdk/README.md                      |   2 +
 packages/plugin-sdk/README.zh-CN.md                |   2 +
 packages/plugin-sdk/src/contracts.ts               | 232 +++++++++++++++
 packages/plugin-sdk/src/generated/runtime.ts       |  27 ++
 packages/plugin-sdk/src/native.ts                  |  10 +
 platform/host-js/tests/application_services.rs     |   2 +
 .../tests/application_services/host_logs.rs        | 310 +++++++++++++++++++
 platform/host-runtime/README.md                    |   2 +
 platform/host-runtime/README.zh-CN.md              |   2 +
 platform/host-runtime/src/application.rs           |   2 +
 platform/host-runtime/src/application/logs.rs      | 148 ++++++++++
 platform/host-runtime/src/lib.rs                   |   1 +
 platform/host-runtime/src/logs.rs                  | 327 +++++++++++++++++++++
 platform/host-runtime/src/logs/filesystem.rs       | 240 +++++++++++++++
 platform/host-runtime/src/logs/tests.rs            | 283 ++++++++++++++++++
 platform/js-runtime/src/plugin/engine.rs           |   6 +-
 .../tests/integration/plugin_vm/captured.rs        |  23 ++
 platform/process/src/detached.rs                   |   8 +-
 platform/process/src/detached/windows.rs           |  31 +-
 plugins/agent/agent-screen/actions.ts              |  18 +-
 plugins/agent/agent-screen/desktop.ts              |   2 +
 plugins/agent/agent-screen/diagnostics.ts          | 213 ++++++++------
 plugins/agent/agent-screen/events.ts               |  11 +-
 plugins/agent/agent-screen/frame.ts                |  22 +-
 plugins/agent/agent-screen/header-view.ts          |   4 +-
 plugins/agent/agent-screen/host-logs.ts            | 264 +++++++++++++++++
 plugins/agent/agent-screen/index.ts                |   2 +
 plugins/agent/agent-screen/locale.ts               |  14 +
 plugins/agent/agent-screen/package.ts              |   2 +
 plugins/agent/agent-screen/results.ts              |  13 +
 plugins/agent/agent-screen/state.ts                |   3 +
 plugins/agent/agent-screen/tui.ts                  |   2 +
 plugins/application/gateway/package.ts             |   1 +
 .../application/host-logs/package.ts               |  26 +-
 plugins/shared/locale-messages.ts                  |  24 ++
 products/maka/bundle/package-inputs.json           |   1 +
 products/maka/bundle/src/composition.ts            |  11 +
 products/maka/bundle/tests/task-workbench.test.mjs | 257 +++++++++++++++-
 products/maka/cli/README.md                        |   2 +
 products/maka/cli/README.zh-CN.md                  |   2 +
 products/maka/cli/notices.json                     |  40 +--
 products/maka/cli/src/application/serve.rs         |  12 +-
 products/maka/cli/src/args.rs                      |   5 +-
 products/maka/cli/src/deployment/activation.rs     |  10 +-
 products/maka/cli/src/deployment/diagnostics.rs    |  13 +-
 products/maka/cli/src/deployment/entry.rs          |   6 +-
 products/maka/cli/src/deployment/local.rs          |   3 +
 products/maka/cli/src/deployment/logs.rs           | 236 ++++-----------
 products/maka/cli/src/deployment/service/macos.rs  |  20 +-
 products/maka/cli/src/logging.rs                   |  83 ++++++
 products/maka/cli/src/serve.rs                     |   5 +
 products/maka/cli/src/windows.rs                   |  70 -----
 products/maka/cli/tests/integration/candidate.rs   |   1 +
 .../cli/tests/integration/candidate/startup.rs     | 321 ++++++++++++++++++++
 .../tests/integration/terminal/bundled_product.rs  |   1 +
 .../terminal/bundled_product/host_logs.rs          | 132 +++++++++
 tools/schema/src/lib.rs                            |   6 +
 57 files changed, 3107 insertions(+), 409 deletions(-)

diff --git a/packages/plugin-sdk/README.md b/packages/plugin-sdk/README.md
index 7e91b254d5..585d7ef56c 100644
--- a/packages/plugin-sdk/README.md
+++ b/packages/plugin-sdk/README.md
@@ -44,4 +44,6 @@ Generation uses [maka-schema](../../tools/schema/README.md) and does not build t
 
 Components have synchronous callbacks that propose a frame and actions. Terminal and desktop use their own scene types. Native controls own editor buffers, selection and IME; capture resources through their declared contracts. Host services receive request-scoped imports and attachments, without ambient Node APIs.
 
+Guest realms provide `TextEncoder`, `TextDecoder`, `atob`, `URL` and `URLSearchParams` for pure text and byte conversion. Decode Base64 to bytes with `Uint8Array.from(atob(value), c => c.charCodeAt(0))`; these APIs grant no filesystem or network access.
+
 TypeScript checks authoring mistakes. The runtime separately validates values, contract compatibility, resource slots and permissions. See the [plugin guide](../../plugins/README.md) and [runnable starter](../../skills/maka-plugin-authoring/references/quickstart.md).
diff --git a/packages/plugin-sdk/README.zh-CN.md b/packages/plugin-sdk/README.zh-CN.md
index c16b616b62..9bd0613b87 100644
--- a/packages/plugin-sdk/README.zh-CN.md
+++ b/packages/plugin-sdk/README.zh-CN.md
@@ -44,4 +44,6 @@ npm --workspace @maka-agent/plugin-sdk run typecheck
 
 组件的同步回调提出帧和动作,终端与桌面使用各自场景类型。原生控件管理编辑器缓冲区、选择和 IME;应通过声明的契约捕获资源。Host 服务接收请求作用域内的导入与附件,不提供环境级 Node API。
 
+客体运行时提供 `TextEncoder`、`TextDecoder`、`atob`、`URL` 和 `URLSearchParams` 进行纯文本与字节转换。可用 `Uint8Array.from(atob(value), c => c.charCodeAt(0))` 将 Base64 解码成字节;这些 API 不授予文件或网络访问权限。
+
 TypeScript 帮助发现编写错误,运行时另外校验值、契约兼容性、资源槽位和权限。参阅[插件指南](../../plugins/README.zh-CN.md)和[可运行示例](../../skills/maka-plugin-authoring/references/quickstart.md)。
diff --git a/packages/plugin-sdk/src/contracts.ts b/packages/plugin-sdk/src/contracts.ts
index ec772c5f43..ce17455fd4 100644
--- a/packages/plugin-sdk/src/contracts.ts
+++ b/packages/plugin-sdk/src/contracts.ts
@@ -53028,6 +53028,238 @@ export const contracts = {
       },
     },
   },
+  hostLogs: {
+    name: 'maka.host.Logs',
+    version: 1,
+    transport: 'remote',
+    interface: {
+      kind: 'service',
+      methods: {
+        read: {
+          mode: 'query',
+          input: {
+            $schema: 'https://json-schema.org/draft/2020-12/schema',
+            title: 'HostLogRequest',
+            type: 'object',
+            properties: {
+              position: {
+                $ref: '#/$defs/HostLogPosition',
+              },
+            },
+            additionalProperties: false,
+            required: ['position'],
+            $defs: {
+              HostLogPosition: {
+                oneOf: [
+                  {
+                    type: 'object',
+                    properties: {
+                      kind: {
+                        type: 'string',
+                        const: 'tail',
+                      },
+                    },
+                    required: ['kind'],
+                    additionalProperties: false,
+                  },
+                  {
+                    type: 'object',
+                    properties: {
+                      cursor: {
+                        $ref: '#/$defs/HostLogCursor',
+                      },
+                      kind: {
+                        type: 'string',
+                        const: 'after',
+                      },
+                    },
+                    additionalProperties: false,
+                    required: ['kind', 'cursor'],
+                  },
+                ],
+              },
+              HostLogCursor: {
+                type: 'string',
+                maxLength: 512,
+              },
+            },
+          },
+          output: {
+            $schema: 'https://json-schema.org/draft/2020-12/schema',
+            title: 'HostLogPage',
+            oneOf: [
+              {
+                type: 'object',
+                properties: {
+                  kind: {
+                    type: 'string',
+                    const: 'missing',
+                  },
+                },
+                required: ['kind'],
+                additionalProperties: false,
+              },
+              {
+                type: 'object',
+                properties: {
+                  cursor: {
+                    $ref: '#/$defs/HostLogCursor',
+                  },
+                  bytes: {
+                    type: 'string',
+                  },
+                  reset: {
+                    anyOf: [
+                      {
+                        $ref: '#/$defs/HostLogReset',
+                      },
+                      {
+                        type: 'null',
+                      },
+                    ],
+                  },
+                  truncated: {
+                    type: 'boolean',
+                  },
+                  hasMore: {
+                    type: 'boolean',
+                  },
+                  kind: {
+                    type: 'string',
+                    const: 'data',
+                  },
+                },
+                additionalProperties: false,
+                required: ['kind', 'cursor', 'bytes', 'truncated', 'hasMore'],
+              },
+            ],
+            $defs: {
+              HostLogCursor: {
+                type: 'string',
+                maxLength: 512,
+              },
+              HostLogReset: {
+                type: 'string',
+                enum: ['replaced', 'truncated'],
+              },
+            },
+          },
+          resources: {},
+        },
+        watch: {
+          mode: 'subscription',
+          input: {
+            $schema: 'https://json-schema.org/draft/2020-12/schema',
+            title: 'HostLogRequest',
+            type: 'object',
+            properties: {
+              position: {
+                $ref: '#/$defs/HostLogPosition',
+              },
+            },
+            additionalProperties: false,
+            required: ['position'],
+            $defs: {
+              HostLogPosition: {
+                oneOf: [
+                  {
+                    type: 'object',
+                    properties: {
+                      kind: {
+                        type: 'string',
+                        const: 'tail',
+                      },
+                    },
+                    required: ['kind'],
+                    additionalProperties: false,
+                  },
+                  {
+                    type: 'object',
+                    properties: {
+                      cursor: {
+                        $ref: '#/$defs/HostLogCursor',
+                      },
+                      kind: {
+                        type: 'string',
+                        const: 'after',
+                      },
+                    },
+                    additionalProperties: false,
+                    required: ['kind', 'cursor'],
+                  },
+                ],
+              },
+              HostLogCursor: {
+                type: 'string',
+                maxLength: 512,
+              },
+            },
+          },
+          output: {
+            $schema: 'https://json-schema.org/draft/2020-12/schema',
+            title: 'HostLogPage',
+            oneOf: [
+              {
+                type: 'object',
+                properties: {
+                  kind: {
+                    type: 'string',
+                    const: 'missing',
+                  },
+                },
+                required: ['kind'],
+                additionalProperties: false,
+              },
+              {
+                type: 'object',
+                properties: {
+                  cursor: {
+                    $ref: '#/$defs/HostLogCursor',
+                  },
+                  bytes: {
+                    type: 'string',
+                  },
+                  reset: {
+                    anyOf: [
+                      {
+                        $ref: '#/$defs/HostLogReset',
+                      },
+                      {
+                        type: 'null',
+                      },
+                    ],
+                  },
+                  truncated: {
+                    type: 'boolean',
+                  },
+                  hasMore: {
+                    type: 'boolean',
+                  },
+                  kind: {
+                    type: 'string',
+                    const: 'data',
+                  },
+                },
+                additionalProperties: false,
+                required: ['kind', 'cursor', 'bytes', 'truncated', 'hasMore'],
+              },
+            ],
+            $defs: {
+              HostLogCursor: {
+                type: 'string',
+                maxLength: 512,
+              },
+              HostLogReset: {
+                type: 'string',
+                enum: ['replaced', 'truncated'],
+              },
+            },
+          },
+          resources: {},
+        },
+      },
+    },
+  },
   workspaceComputer: {
     name: 'maka.workspace.Computer',
     version: 1,
diff --git a/packages/plugin-sdk/src/generated/runtime.ts b/packages/plugin-sdk/src/generated/runtime.ts
index 2142f678bd..8e2d776df4 100644
--- a/packages/plugin-sdk/src/generated/runtime.ts
+++ b/packages/plugin-sdk/src/generated/runtime.ts
@@ -20,6 +20,8 @@
 // Generated from Rust runtime schemas. Run npm run schema:generate -w @maka-agent/plugin-sdk.
 
 export type RuntimeProtocol =
+  | HostLogRequest
+  | HostLogPage
   | UiAuthoringCommand
   | UiAuthoringClaim
   | UiAuthoringState
@@ -341,6 +343,28 @@ export type RuntimeProtocol =
   | ResourceMetadata
   | MarkdownTextInput
   | MarkdownTextResult;
+export type HostLogPosition =
+  | {
+      kind: 'tail';
+    }
+  | {
+      cursor: HostLogCursor;
+      kind: 'after';
+    };
+export type HostLogCursor = string;
+export type HostLogPage =
+  | {
+      kind: 'missing';
+    }
+  | {
+      cursor: HostLogCursor;
+      bytes: string;
+      reset?: HostLogReset | null;
+      truncated: boolean;
+      hasMore: boolean;
+      kind: 'data';
+    };
+export type HostLogReset = 'replaced' | 'truncated';
 export type UiAuthoringCommand =
   | {
       method: 'instances';
@@ -5081,6 +5105,9 @@ export type MarkdownTextResult =
       kind: 'too_large';
     };
 
+export interface HostLogRequest {
+  position: HostLogPosition;
+}
 export interface DeploymentRef {
   stateDomain: string;
   deployment: string;
diff --git a/packages/plugin-sdk/src/native.ts b/packages/plugin-sdk/src/native.ts
index c224c95a17..dbdbf50287 100644
--- a/packages/plugin-sdk/src/native.ts
+++ b/packages/plugin-sdk/src/native.ts
@@ -402,6 +402,11 @@ export const nativeImplementations = /* @__PURE__ */ ((): {
     configuration: { type: 'object'; properties: {}; required: []; additionalProperties: false };
     exports: { Authentication: typeof contracts.settingsAuthentication };
   };
+  hostLogs: {
+    implementation: string;
+    configuration: { type: 'object'; properties: {}; required: []; additionalProperties: false };
+    exports: { HostLogs: typeof contracts.hostLogs };
+  };
   settingsSetup: {
     implementation: string;
     configuration: { type: 'object'; properties: {}; required: []; additionalProperties: false };
@@ -787,6 +792,11 @@ export const nativeImplementations = /* @__PURE__ */ ((): {
     configuration: { type: 'object', properties: {}, required: [], additionalProperties: false },
     exports: { Authentication: contracts.settingsAuthentication },
   },
+  hostLogs: {
+    implementation: 'maka.host.logs',
+    configuration: { type: 'object', properties: {}, required: [], additionalProperties: false },
+    exports: { HostLogs: contracts.hostLogs },
+  },
   settingsSetup: {
     implementation: 'maka.settings.setup',
     configuration: { type: 'object', properties: {}, required: [], additionalProperties: false },
diff --git a/platform/host-js/tests/application_services.rs b/platform/host-js/tests/application_services.rs
index 76f73b1c16..6ad83762ce 100644
--- a/platform/host-js/tests/application_services.rs
+++ b/platform/host-js/tests/application_services.rs
@@ -21,6 +21,8 @@
 mod caller;
 #[path = "application_services/execution_basis.rs"]
 mod execution_basis;
+#[path = "application_services/host_logs.rs"]
+mod host_logs;
 #[path = "application_services/management.rs"]
 mod management;
 #[path = "application_services/native_context.rs"]
diff --git a/platform/host-js/tests/application_services/host_logs.rs b/platform/host-js/tests/application_services/host_logs.rs
new file mode 100644
index 0000000000..666fadfc94
--- /dev/null
+++ b/platform/host-js/tests/application_services/host_logs.rs
@@ -0,0 +1,310 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use super::*;
+use maka_event_log::{
+    EventLog,
+    root::{RootNamespaces, RootOwner},
+};
+use maka_host_runtime::{
+    application::{
+        host::{Client, Host},
+        transport::{Grant as EndpointGrant, HostEndpoint, wire::Reply},
+    },
+    ipc::LocalListener,
+};
+use std::io::Write;
+
+fn sources() -> (Input, Vec<Package>) {
+    let contract = maka_host_runtime::application::logs::contract();
+    let logs = package(
+        "test.host.logs",
+        EntryDeclaration {
+            scene: None,
+            implementation: Implementation::Native {
+                implementation: "maka.host.logs".into(),
+            },
+            configuration: json!({"type":"object","additionalProperties":false}),
+            imports: BTreeMap::new(),
+            exports: BTreeMap::from([(
+                "HostLogs".into(),
+                Export {
+                    contract: contract.clone(),
+                    availability: Availability::Required,
+                },
+            )]),
+        },
+        None,
+    );
+    let consumer = package(
+        "test.host.logs.client",
+        EntryDeclaration {
+            scene: None,
+            implementation: Implementation::JavaScript {
+                module: "host.mjs".into(),
+                realm: Realm::Dedicated,
+            },
+            configuration: json!({"type":"object"}),
+            exports: BTreeMap::new(),
+            imports: BTreeMap::from([(
+                "logs".into(),
+                Import {
+                    contract,
+                    cardinality: Cardinality::One,
+                },
+            )]),
+        },
+        Some("export function activate(){return {}; }".into()),
+    );
+    let broken = package(
+        "test.host.logs.unrelated",
+        EntryDeclaration {
+            scene: None,
+            implementation: Implementation::JavaScript {
+                module: "host.mjs".into(),
+                realm: Realm::Dedicated,
+            },
+            configuration: json!({"type":"object"}),
+            imports: BTreeMap::new(),
+            exports: BTreeMap::new(),
+        },
+        Some(
+            "export function activate(){throw new Error('unrelated business initializer failed');}"
+                .into(),
+        ),
+    );
+    let mut client = node(&consumer, 2, true);
+    client.bindings = BTreeMap::from([(
+        "logs".into(),
+        vec![Target::Instance {
+            path: vec!["logs".into()],
+            export: "HostLogs".into(),
+        }],
+    )]);
+    (
+        Input {
+            environment: Environment {
+                location: ExecutionLocation::Host,
+                presentation: None,
+            },
+            instances: BTreeMap::from([
+                ("logs".into(), node(&logs, 1, true)),
+                ("client".into(), client),
+                ("unrelated".into(), node(&broken, 3, true)),
+            ]),
+            recovery: None,
+            launch: None,
+        },
+        vec![logs, consumer, broken],
+    )
+}
+
+#[tokio::test]
+async fn fixed_root_logs_public_endpoint_survives_unrelated_initialization_failure() {
+    tokio::time::timeout(Duration::from_secs(30), async {
+        let directory = tempfile::tempdir().unwrap();
+        let namespaces = RootNamespaces {
+            ownership: directory.path().join("owners"),
+            control: directory.path().join("control"),
+        };
+        let owner =
+            Arc::new(RootOwner::create(&directory.path().join("root"), &namespaces).unwrap());
+        let mut capture = maka_host_runtime::logs::Source::from_owner(&owner)
+            .unwrap()
+            .open_capture()
+            .unwrap();
+        capture.write_all(b"before\n").unwrap();
+        capture.flush().unwrap();
+        let store = Arc::new(EventLog::for_root(owner).await.unwrap());
+        let (input, packages) = sources();
+        let source = store
+            .commit_application(Uuid::new_v4(), 0, input, vec![], packages)
+            .await
+            .unwrap();
+        let host = Host::open(
+            store.clone(),
+            source.deployment,
+            NativeRegistry::default(),
+            Some(maka_host_js::factory(Limits::default())),
+            [("logs", vec!["read", "watch"]), ("readOnly", vec!["read"])]
+                .into_iter()
+                .map(|(name, methods)| {
+                    (
+                        name.into(),
+                        HostEndpoint {
+                            consumer: id(2),
+                            import: "logs".into(),
+                            grant: EndpointGrant {
+                                methods: methods.into_iter().map(str::to_owned).collect(),
+                                recovery: false,
+                            },
+                        },
+                    )
+                })
+                .collect(),
+            vec![],
+        )
+        .await
+        .unwrap();
+        let ipc = directory.path().join("ipc");
+        maka_event_log::root::private_directory(&ipc).unwrap();
+        let address = ipc.join("host.sock");
+        let listener = LocalListener::bind(&address).unwrap();
+        let stop = CancellationToken::new();
+        let served = {
+            let host = host.clone();
+            let stop = stop.clone();
+            tokio::spawn(async move { host.serve_local(listener, stop).await })
+        };
+        let mut client = Client::connect(&address, Some(host.epoch())).await.unwrap();
+        let startup_error = client.catalogue().startup_error().map(str::to_owned);
+        let observed = async {
+            let read = query(&client, "logs", json!({"position":{"kind":"tail"}})).await?;
+            let path_error = query(
+                &client,
+                "logs",
+                json!({"position":{"kind":"tail"},"path":"/ungranted/private.log"}),
+            )
+            .await
+            .err();
+            let mut denied = client
+                .call(
+                    "readOnly",
+                    "watch".into(),
+                    json!({"position":{"kind":"tail"}}),
+                    Attachments::new(),
+                )
+                .await?;
+            // Method grants reject before resource reservation; start consumes
+            // that terminal Aborted reply and returns its reason directly.
+            let denied_reason = denied
+                .start()
+                .await
+                .err()
+                .ok_or("ungranted watch unexpectedly reserved resources")?;
+            denied.close().await?;
+            if !denied.cleanup_confirmed() {
+                return Err("rejected watch cleanup unconfirmed".into());
+            }
+            let mut watch = subscribe(&client, json!({"position":{"kind":"tail"}})).await?;
+            let initial = item(&mut watch).await?;
+            capture
+                .write_all(b"after\n")
+                .map_err(|error| error.to_string())?;
+            capture.flush().map_err(|error| error.to_string())?;
+            let appended = item(&mut watch).await?;
+            watch.close().await?;
+            if !watch.cleanup_confirmed() {
+                return Err("watch cancellation did not confirm physical cleanup".into());
+            }
+            let cursor = appended["cursor"].clone();
+            client.close().await?;
+            client = Client::connect(&address, Some(host.epoch())).await?;
+            capture
+                .write_all(b"reconnect\n")
+                .map_err(|error| error.to_string())?;
+            capture.flush().map_err(|error| error.to_string())?;
+            let mut resumed = subscribe(
+                &client,
+                json!({"position":{"kind":"after","cursor":cursor}}),
+            )
+            .await?;
+            let reconnected = item(&mut resumed).await?;
+            resumed.close().await?;
+            if !resumed.cleanup_confirmed() {
+                return Err("reconnected watch cleanup unconfirmed".into());
+            }
+            Ok::<_, String>((
+                read,
+                initial,
+                appended,
+                reconnected,
+                denied_reason,
+                path_error,
+            ))
+        }
+        .await;
+        client.close().await.unwrap();
+        stop.cancel();
+        served.await.unwrap().unwrap();
+        host.shutdown().await.unwrap();
+        drop(host);
+        let store = Arc::try_unwrap(store)
+            .unwrap_or_else(|_| panic!("Host cleanup retained the original EventLog"));
+        store.close().await.unwrap();
+        let (read, initial, appended, reconnected, denied_reason, path_error) = observed.expect(
+            "explicitly granted public HostLogs must read and follow the original Root capture",
+        );
+        assert_eq!(read["kind"], "data");
+        assert_eq!(read["bytes"], "YmVmb3JlCg==");
+        assert_eq!(read["reset"], Value::Null);
+        assert_eq!(read["truncated"], false);
+        assert_eq!(read["hasMore"], false);
+        assert_eq!(initial["bytes"], "YmVmb3JlCg==");
+        assert_eq!(appended["bytes"], "YWZ0ZXIK");
+        assert_eq!(reconnected["bytes"], "cmVjb25uZWN0Cg==");
+        assert_eq!(reconnected["reset"], Value::Null);
+        assert_eq!(denied_reason, "Application method is not granted");
+        assert!(path_error.is_some(), "request fields cannot choose a path");
+        assert!(
+            startup_error
+                .unwrap()
+                .contains("unrelated business initializer failed")
+        );
+    })
+    .await
+    .unwrap();
+}
+
+async fn query(client: &Client, endpoint: &str, input: Value) -> Result<Value> {
+    let mut call = client
+        .call(endpoint, "read".into(), input, Attachments::new())
+        .await?;
+    call.start().await?;
+    let reply = call.receive().await?;
+    call.close().await?;
+    match reply {
+        Reply::Finished { result, cleanup } => {
+            cleanup?;
+            result
+        }
+        other => Err(format!("unexpected log query reply: {other:?}")),
+    }
+}
+async fn subscribe(
+    client: &Client,
+    input: Value,
+) -> Result<maka_host_runtime::application::host::LocalCall> {
+    let mut call = client
+        .call("logs", "watch".into(), input, Attachments::new())
+        .await?;
+    call.start().await?;
+    let reply = call.receive().await?;
+    if !matches!(reply, Reply::Subscribed) {
+        call.close().await?;
+        return Err(format!("unexpected log subscription reply: {reply:?}"));
+    }
+    Ok(call)
+}
+async fn item(call: &mut maka_host_runtime::application::host::LocalCall) -> Result<Value> {
+    match call.pull().await? {
+        Reply::Item { value } => Ok(value),
+        other => Err(format!("unexpected log subscription item: {other:?}")),
+    }
+}
diff --git a/platform/host-runtime/README.md b/platform/host-runtime/README.md
index f8a7309532..35484b0fc2 100644
--- a/platform/host-runtime/README.md
+++ b/platform/host-runtime/README.md
@@ -27,4 +27,6 @@ A committed source is desired configuration. It becomes effective only after its
 
 Persistence comes from the owned EventLog. Stable plugin data belongs to the Root, deployment and instance; an activation epoch identifies execution, not a new data owner.
 
+The built-in `maka.host.logs` provider derives its fixed log source from that same Root owner. Its `HostLogs` export (`maka.host.Logs`) offers bounded raw-byte `read` queries and cancellable `watch` subscriptions. Compositions must bind and explicitly grant these methods; registration grants no access, and requests cannot select another Root or file. See the generated SDK contract for page and cursor fields.
+
 [Source](src/lib.rs) · [Cargo manifest](Cargo.toml)
diff --git a/platform/host-runtime/README.zh-CN.md b/platform/host-runtime/README.zh-CN.md
index 47218d8b36..676f10435c 100644
--- a/platform/host-runtime/README.zh-CN.md
+++ b/platform/host-runtime/README.zh-CN.md
@@ -27,4 +27,6 @@
 
 持久化由已持有的 EventLog 提供。插件数据归属于 Root、部署和实例;激活 epoch 标识一次执行,不会创建新的数据所有者。
 
+内置 `maka.host.logs` provider 从同一个 Root 所有者派生日志源。其 `HostLogs` 导出(`maka.host.Logs`)提供有界原始字节 `read` 查询和可取消的 `watch` 订阅。组合必须绑定并显式授权这些方法;注册本身不授予访问权,请求也不能选择其他 Root 或文件。页和游标字段见生成的 SDK 契约。
+
 [源码](src/lib.rs) · [Cargo 配置](Cargo.toml)
diff --git a/platform/host-runtime/src/application.rs b/platform/host-runtime/src/application.rs
index ac06b19ece..0eab42def9 100644
--- a/platform/host-runtime/src/application.rs
+++ b/platform/host-runtime/src/application.rs
@@ -23,6 +23,7 @@ pub mod backend;
 mod caller;
 mod context;
 pub mod host;
+pub mod logs;
 pub mod management;
 mod native_context;
 pub mod operations;
@@ -730,6 +731,7 @@ impl Application {
         }
         operations::provide(&mut native)?;
         management::provide(&mut native)?;
+        logs::provide(&mut native)?;
         let (source, packages, plan, snapshot) = loaded.into_parts();
         Self::start_owned(
             source.input,
diff --git a/platform/host-runtime/src/application/logs.rs b/platform/host-runtime/src/application/logs.rs
new file mode 100644
index 0000000000..16f4fc7261
--- /dev/null
+++ b/platform/host-runtime/src/application/logs.rs
@@ -0,0 +1,148 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+//! Explicitly granted reads of the original Root's fixed raw stderr capture.
+
+use super::*;
+use crate::logs::{HostLogPage, HostLogRequest, Source};
+use maka_plugin_core::contract::{Contract, Interface, Method, MethodMode, Transport};
+use serde::Deserialize;
+
+pub const HOST_LOGS: &str = "maka.host.logs";
+
+struct Logs(Source);
+impl Handler for Logs {
+    fn call(
+        &self,
+        method: String,
+        input: Value,
+        context: Context,
+    ) -> BoxFuture<'static, Result<Value>> {
+        let source = self.0.clone();
+        Box::pin(async move {
+            let request: HostLogRequest =
+                serde_json::from_value(input).map_err(|error| error.to_string())?;
+            match method.as_str() {
+                "read" => page(
+                    source
+                        .read(request)
+                        .await
+                        .map_err(|error| error.to_string())?,
+                ),
+                "watch" => {
+                    source
+                        .watch(request, context.scope().cancellation.clone(), |value| {
+                            let emitted = page(value).map(|value| context.emit(value));
+                            async move {
+                                emitted
+                                    .map_err(std::io::Error::other)?
+                                    .await
+                                    .map_err(std::io::Error::other)
+                            }
+                        })
+                        .await
+                        .map_err(|error| error.to_string())?;
+                    Ok(Value::Null)
+                }
+                _ => Err("unknown Host logs method".into()),
+            }
+        })
+    }
+}
+fn page(page: HostLogPage) -> Result<Value> {
+    let value = serde_json::to_value(page).map_err(|error| error.to_string())?;
+    maka_plugin_core::json::check_delivery(&value, 48 * 1024)?;
+    Ok(value)
+}
+
+pub(super) fn provide(native: &mut NativeRegistry) -> Result<()> {
+    #[derive(Deserialize)]
+    #[serde(deny_unknown_fields)]
+    struct Configuration {}
+    native.insert(
+        HOST_LOGS.into(),
+        Arc::new(|context, configuration| {
+            Box::pin(async move {
+                let exports = (|| {
+                    serde_json::from_value::<Configuration>(configuration)
+                        .map_err(|error| error.to_string())?;
+                    let persistence = context
+                        .persistence()
+                        .ok_or("Host logs require the original persisted Root")?;
+                    let root = persistence
+                        .store()
+                        .owned_root()
+                        .map_err(|error| error.to_string())?;
+                    let source = Source::from_owner(&root).map_err(|error| error.to_string())?;
+                    Ok(BTreeMap::from([(
+                        "HostLogs".into(),
+                        Published {
+                            contract: contract(),
+                            value: Arc::new(Logs(source)) as Arc<dyn Handler>,
+                        },
+                    )]))
+                })();
+                // Every read/follow belongs to its original Application invocation.
+                NativeActivation {
+                    exports,
+                    owner: Arc::new(()),
+                }
+            })
+        }),
+    )
+}
+
+pub fn contract() -> Contract {
+    let input = serde_json::to_value(schemars::schema_for!(HostLogRequest))
+        .expect("Host log request schema");
+    let output =
+        serde_json::to_value(schemars::schema_for!(HostLogPage)).expect("Host log page schema");
+    Contract {
+        name: "maka.host.Logs".into(),
+        version: 1,
+        transport: Transport::Remote,
+        interface: Interface::Service {
+            methods: [
+                ("read", MethodMode::Query),
+                ("watch", MethodMode::Subscription),
+            ]
+            .into_iter()
+            .map(|(name, mode)| {
+                (
+                    name.into(),
+                    Method {
+                        mode,
+                        input: input.clone(),
+                        output: output.clone(),
+                        progress: None,
+                        resources: BTreeMap::new(),
+                    },
+                )
+            })
+            .collect(),
+        },
+    }
+}
+pub fn native() -> maka_plugin_core::package::NativeDefinition {
+    maka_plugin_core::package::NativeDefinition::new(
+        HOST_LOGS,
+        serde_json::json!({"type":"object","properties":{},"required":[],"additionalProperties":false}),
+        [("HostLogs", contract())],
+    )
+}
diff --git a/platform/host-runtime/src/lib.rs b/platform/host-runtime/src/lib.rs
index 147389f96c..8e8432d6fe 100644
--- a/platform/host-runtime/src/lib.rs
+++ b/platform/host-runtime/src/lib.rs
@@ -21,6 +21,7 @@ pub mod application;
 mod directories;
 pub mod http;
 pub mod ipc;
+pub mod logs;
 pub mod recovery;
 pub mod sandbox;
 pub mod shell;
diff --git a/platform/host-runtime/src/logs.rs b/platform/host-runtime/src/logs.rs
new file mode 100644
index 0000000000..dc2fdc15bd
--- /dev/null
+++ b/platform/host-runtime/src/logs.rs
@@ -0,0 +1,327 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+//! Fixed-Root raw stderr bytes. Readers never retain the Root writer lease.
+//!
+//! Writers append; replacement and observed shrink reset cursors. Copytruncate
+//! followed by regrowth between reads and hostile file-ID reuse are not detected.
+//! Cancellation settles any started blocking read; a stalled filesystem syscall
+//! has no promised wall-clock cancellation deadline.
+
+mod filesystem;
+#[cfg(test)]
+mod tests;
+
+use base64::{
+    Engine,
+    engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD},
+};
+use maka_event_log::root::{RootNamespaces, RootOwner};
+use schemars::JsonSchema;
+use serde::{Deserialize, Deserializer, Serialize};
+use std::{
+    fmt,
+    fs::File,
+    future::Future,
+    io,
+    path::{Path, PathBuf},
+    str::FromStr,
+    time::Duration,
+};
+use tokio_util::sync::CancellationToken;
+
+const RAW_LIMIT: usize = 32 * 1024;
+const CURSOR_LIMIT: usize = 512;
+
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, JsonSchema)]
+#[serde(transparent)]
+pub struct HostLogCursor(#[schemars(length(max = 512))] String);
+
+#[derive(Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+struct CursorData {
+    root: String,
+    device: u64,
+    file: u64,
+    offset: u64,
+}
+
+impl HostLogCursor {
+    pub fn as_str(&self) -> &str {
+        &self.0
+    }
+
+    fn decode(&self) -> io::Result<CursorData> {
+        if self.0.len() > CURSOR_LIMIT {
+            return Err(invalid("log cursor is too long"));
+        }
+        let raw = URL_SAFE_NO_PAD
+            .decode(&self.0)
+            .map_err(|_| invalid("invalid log cursor"))?;
+        let data: CursorData =
+            serde_json::from_slice(&raw).map_err(|_| invalid("invalid log cursor"))?;
+        validate_root(&data.root)?;
+        Ok(data)
+    }
+
+    fn encode(data: CursorData) -> io::Result<Self> {
+        let bytes = serde_json::to_vec(&data).map_err(io::Error::other)?;
+        Ok(Self(URL_SAFE_NO_PAD.encode(bytes)))
+    }
+}
+
+impl fmt::Display for HostLogCursor {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        f.write_str(&self.0)
+    }
+}
+impl FromStr for HostLogCursor {
+    type Err = io::Error;
+    fn from_str(value: &str) -> io::Result<Self> {
+        Self::try_from(value.to_owned())
+    }
+}
+impl TryFrom<String> for HostLogCursor {
+    type Error = io::Error;
+    fn try_from(value: String) -> io::Result<Self> {
+        let cursor = Self(value);
+        cursor.decode()?;
+        Ok(cursor)
+    }
+}
+impl<'de> Deserialize<'de> for HostLogCursor {
+    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
+        Self::try_from(String::deserialize(deserializer)?).map_err(serde::de::Error::custom)
+    }
+}
+
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
+#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
+pub enum HostLogPosition {
+    Tail,
+    After { cursor: HostLogCursor },
+}
+
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
+#[serde(rename_all = "camelCase", deny_unknown_fields)]
+pub struct HostLogRequest {
+    pub position: HostLogPosition,
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
+#[serde(rename_all = "snake_case")]
+pub enum HostLogReset {
+    Replaced,
+    Truncated,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
+#[serde(
+    tag = "kind",
+    rename_all = "snake_case",
+    rename_all_fields = "camelCase",
+    deny_unknown_fields
+)]
+pub enum HostLogPage {
+    Missing,
+    Data {
+        cursor: HostLogCursor,
+        bytes: String,
+        reset: Option<HostLogReset>,
+        truncated: bool,
+        has_more: bool,
+    },
+}
+
+#[derive(Clone, Debug)]
+pub struct Source {
+    root_id: String,
+    path: PathBuf,
+}
+
+impl Source {
+    pub fn from_owner(owner: &RootOwner) -> io::Result<Self> {
+        owner.validate_current()?;
+        Ok(Self {
+            root_id: owner.root_id().to_owned(),
+            path: owner.control_directory().join("host.stderr.log"),
+        })
+    }
+
+    /// Derive a source without opening, creating, or changing any filesystem entry.
+    pub fn for_root(root_id: &str, namespaces: &RootNamespaces) -> io::Result<Self> {
+        validate_root(root_id)?;
+        Ok(Self {
+            root_id: root_id.to_owned(),
+            path: namespaces.control.join(root_id).join("host.stderr.log"),
+        })
+    }
+
+    pub fn path(&self) -> &Path {
+        &self.path
+    }
+
+    /// Safely create/open the fixed private file for append-only process capture.
+    pub fn open_capture(&self) -> io::Result<File> {
+        filesystem::capture(&self.path)
+    }
+
+    pub async fn read(&self, request: HostLogRequest) -> io::Result<HostLogPage> {
+        let source = self.clone();
+        tokio::task::spawn_blocking(move || source.read_blocking(request))
+            .await
+            .map_err(io::Error::other)?
+    }
+
+    /// Serial delivery supplies backpressure without a queue or detached worker.
+    pub async fn watch<F, Fut>(
+        &self,
+        request: HostLogRequest,
+        cancel: CancellationToken,
+        mut emit: F,
+    ) -> io::Result<()>
+    where
+        F: FnMut(HostLogPage) -> Fut,
+        Fut: Future<Output = io::Result<()>>,
+    {
+        let mut position = request.position;
+        let mut previous = None;
+        loop {
+            if cancel.is_cancelled() {
+                return Ok(());
+            }
+            // Do not abandon spawn_blocking on cancellation. Await this bounded
+            // read before returning, then discard its result if cancelled.
+            let result = self
+                .read(HostLogRequest {
+                    position: position.clone(),
+                })
+                .await;
+            if cancel.is_cancelled() {
+                return Ok(());
+            }
+            let page = result?;
+            let mut has_more = false;
+            if let HostLogPage::Data {
+                cursor,
+                has_more: more,
+                ..
+            } = &page
+            {
+                position = HostLogPosition::After {
+                    cursor: cursor.clone(),
+                };
+                has_more = *more;
+            }
+            let changed = match (&page, &previous) {
+                (
+                    HostLogPage::Data {
+                        cursor,
+                        bytes,
+                        reset: None,
+                        ..
+                    },
+                    Some(HostLogPage::Data { cursor: prior, .. }),
+                ) if bytes.is_empty() => cursor != prior,
+                _ => previous.as_ref() != Some(&page),
+            };
+            if changed {
+                tokio::select! {
+                    biased;
+                    _ = cancel.cancelled() => return Ok(()),
+                    result = emit(page.clone()) => result?,
+                }
+            }
+            previous = Some(page);
+            if !has_more {
+                tokio::select! {
+                    _ = cancel.cancelled() => return Ok(()),
+                    _ = tokio::time::sleep(Duration::from_millis(250)) => {},
+                }
+            }
+        }
+    }
+
+    fn read_blocking(&self, request: HostLogRequest) -> io::Result<HostLogPage> {
+        use std::io::{Read, Seek, SeekFrom};
+        let tail = matches!(&request.position, HostLogPosition::Tail);
+        let after = match request.position {
+            HostLogPosition::Tail => None,
+            HostLogPosition::After { cursor } => {
+                let data = cursor.decode()?;
+                if data.root != self.root_id {
+                    return Err(invalid("log cursor belongs to another Root"));
+                }
+                Some(data)
+            }
+        };
+        let Some(mut file) = filesystem::read(&self.path)? else {
+            return Ok(HostLogPage::Missing);
+        };
+        let (device, identity) = filesystem::identity(&file)?;
+        let end = file.metadata()?.len();
+        let (start, reset) = match after {
+            Some(cursor) if (cursor.device, cursor.file) != (device, identity) => (
+                end.saturating_sub(RAW_LIMIT as u64),
+                Some(HostLogReset::Replaced),
+            ),
+            Some(cursor) if cursor.offset > end => (
+                end.saturating_sub(RAW_LIMIT as u64),
+                Some(HostLogReset::Truncated),
+            ),
+            Some(cursor) => (cursor.offset, None),
+            None => (end.saturating_sub(RAW_LIMIT as u64), None),
+        };
+        file.seek(SeekFrom::Start(start))?;
+        let mut bytes = Vec::with_capacity(RAW_LIMIT);
+        file.take((end - start).min(RAW_LIMIT as u64))
+            .read_to_end(&mut bytes)?;
+        let offset = start + bytes.len() as u64;
+        let cursor = HostLogCursor::encode(CursorData {
+            root: self.root_id.clone(),
+            device,
+            file: identity,
+            offset,
+        })?;
+        Ok(HostLogPage::Data {
+            cursor,
+            bytes: STANDARD.encode(bytes),
+            reset,
+            truncated: start != 0 && (tail || reset.is_some()),
+            has_more: offset < end,
+        })
+    }
+}
+
+fn invalid(message: &str) -> io::Error {
+    io::Error::new(io::ErrorKind::InvalidInput, message)
+}
+fn validate_root(root: &str) -> io::Result<()> {
+    if root.len() == 64
+        && root
+            .bytes()
+            .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
+    {
+        Ok(())
+    } else {
+        Err(invalid(
+            "Root ID must contain 64 lowercase hexadecimal characters",
+        ))
+    }
+}
diff --git a/platform/host-runtime/src/logs/filesystem.rs b/platform/host-runtime/src/logs/filesystem.rs
new file mode 100644
index 0000000000..db9634bec3
--- /dev/null
+++ b/platform/host-runtime/src/logs/filesystem.rs
@@ -0,0 +1,240 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use std::{fs::File, io, path::Path};
+
+fn denied() -> io::Error {
+    io::Error::new(
+        io::ErrorKind::PermissionDenied,
+        "Host log must be private, regular, singly linked, and stably named",
+    )
+}
+
+pub(super) fn capture(path: &Path) -> io::Result<File> {
+    let parent = path.parent().ok_or_else(denied)?;
+    maka_event_log::root::private_directory(parent)?;
+    open(path, true)?.ok_or_else(denied)
+}
+
+pub(super) fn read(path: &Path) -> io::Result<Option<File>> {
+    open(path, false)
+}
+
+#[cfg(unix)]
+pub(super) fn identity(file: &File) -> io::Result<(u64, u64)> {
+    use std::os::unix::fs::MetadataExt;
+    let metadata = file.metadata()?;
+    Ok((metadata.dev(), metadata.ino()))
+}
+
+#[cfg(windows)]
+pub(super) fn identity(file: &File) -> io::Result<(u64, u64)> {
+    let id = maka_event_log::root::windows::file_identity(file)?;
+    Ok((id.volume, id.index))
+}
+
+#[cfg(unix)]
+fn directory(path: &Path) -> io::Result<File> {
+    use std::{
+        fs::OpenOptions,
+        os::unix::fs::{MetadataExt, OpenOptionsExt},
+    };
+    let file = OpenOptions::new()
+        .read(true)
+        .custom_flags(libc::O_NOFOLLOW | libc::O_DIRECTORY | libc::O_NONBLOCK)
+        .open(path)?;
+    let metadata = file.metadata()?;
+    if !metadata.is_dir()
+        || metadata.uid() != unsafe { libc::geteuid() }
+        || metadata.mode() & 0o077 != 0
+    {
+        return Err(denied());
+    }
+    Ok(file)
+}
+
+#[cfg(unix)]
+fn open(path: &Path, create: bool) -> io::Result<Option<File>> {
+    use std::{
+        ffi::CString,
+        os::{
+            fd::{AsRawFd, FromRawFd},
+            unix::fs::MetadataExt,
+        },
+    };
+    let parent = path.parent().ok_or_else(denied)?;
+    let directory = match directory(parent) {
+        Ok(directory) => directory,
+        Err(error) if !create && error.kind() == io::ErrorKind::NotFound => return Ok(None),
+        Err(error) => return Err(error),
+    };
+    // The basename is fixed by Source, never accepted from a wire request.
+    let name = CString::new("host.stderr.log").map_err(io::Error::other)?;
+    let flags = libc::O_NOFOLLOW
+        | libc::O_NONBLOCK
+        | libc::O_CLOEXEC
+        | if create {
+            libc::O_WRONLY | libc::O_APPEND | libc::O_CREAT
+        } else {
+            libc::O_RDONLY
+        };
+    // SAFETY: owned directory FD and NUL-terminated fixed basename; mode is only
+    // used for O_CREAT. Relative open pins the checked containing directory.
+    let fd = unsafe { libc::openat(directory.as_raw_fd(), name.as_ptr(), flags, 0o600) };
+    if fd < 0 {
+        let error = io::Error::last_os_error();
+        if !create && error.kind() == io::ErrorKind::NotFound {
+            stable_directory(&directory, parent)?;
+            return Ok(None);
+        }
+        return Err(error);
+    }
+    // SAFETY: openat returned exactly one new owned FD.
+    let file = unsafe { File::from_raw_fd(fd) };
+    let metadata = file.metadata()?;
+    if !metadata.is_file()
+        || metadata.uid() != unsafe { libc::geteuid() }
+        || metadata.mode() & 0o077 != 0
+        || metadata.nlink() != 1
+    {
+        return Err(denied());
+    }
+    let mut named = std::mem::MaybeUninit::<libc::stat>::uninit();
+    // SAFETY: live directory/name and correctly sized output storage.
+    if unsafe {
+        libc::fstatat(
+            directory.as_raw_fd(),
+            name.as_ptr(),
+            named.as_mut_ptr(),
+            libc::AT_SYMLINK_NOFOLLOW,
+        )
+    } != 0
+    {
+        return Err(io::Error::last_os_error());
+    }
+    // SAFETY: successful fstatat initialized the entire record.
+    let named = unsafe { named.assume_init() };
+    // libc's device ID width differs between macOS and Linux.
+    #[allow(clippy::unnecessary_cast)]
+    let named_device = named.st_dev as u64;
+    if (metadata.dev(), metadata.ino()) != (named_device, named.st_ino) {
+        return Err(denied());
+    }
+    stable_directory(&directory, parent)?;
+    Ok(Some(file))
+}
+
+#[cfg(unix)]
+fn stable_directory(file: &File, path: &Path) -> io::Result<()> {
+    let named = directory(path)?;
+    if identity(file)? != identity(&named)? {
+        return Err(denied());
+    }
+    Ok(())
+}
+
+#[cfg(windows)]
+fn directory(path: &Path) -> io::Result<File> {
+    use maka_event_log::root::windows::{open_nofollow, validate_private};
+    let file = open_nofollow(path, false)?;
+    validate_private(&file)?;
+    if !file.metadata()?.is_dir() {
+        return Err(denied());
+    }
+    Ok(file)
+}
+
+#[cfg(windows)]
+fn open(path: &Path, create: bool) -> io::Result<Option<File>> {
+    use maka_event_log::root::windows::{file_identity, open_nofollow, validate_private};
+    let parent = path.parent().ok_or_else(denied)?;
+    let parent_handle = match directory(parent) {
+        Ok(file) => file,
+        Err(error) if !create && error.kind() == io::ErrorKind::NotFound => return Ok(None),
+        Err(error) => return Err(error),
+    };
+    let opened = if create {
+        append(path)
+    } else {
+        open_nofollow(path, false)
+    };
+    let file = match opened {
+        Ok(file) => file,
+        Err(error) if !create && error.kind() == io::ErrorKind::NotFound => {
+            if identity(&parent_handle)? != identity(&directory(parent)?)? {
+                return Err(denied());
+            }
+            return Ok(None);
+        }
+        Err(error) => return Err(error),
+    };
+    validate_private(&file)?;
+    let id = file_identity(&file)?;
+    if !file.metadata()?.is_file() || id.links != 1 {
+        return Err(denied());
+    }
+    let named = open_nofollow(path, false)?;
+    if identity(&file)? != identity(&named)?
+        || identity(&parent_handle)? != identity(&directory(parent)?)?
+    {
+        return Err(denied());
+    }
+    Ok(Some(file))
+}
+
+#[cfg(windows)]
+fn append(path: &Path) -> io::Result<File> {
+    use maka_event_log::root::windows::PrivateSecurity;
+    use std::{
+        os::windows::{ffi::OsStrExt, fs::MetadataExt, io::FromRawHandle},
+        ptr,
+    };
+    use windows_sys::Win32::{
+        Foundation::{GENERIC_READ, INVALID_HANDLE_VALUE},
+        Storage::FileSystem::{
+            CreateFileW, FILE_APPEND_DATA, FILE_ATTRIBUTE_REPARSE_POINT,
+            FILE_FLAG_OPEN_REPARSE_POINT, FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE,
+            OPEN_ALWAYS,
+        },
+    };
+    let name: Vec<u16> = path.as_os_str().encode_wide().chain(Some(0)).collect();
+    let security = PrivateSecurity::current_account()?;
+    // SAFETY: owned pathname/private descriptor remain live for CreateFileW;
+    // append-only access and shared delete allow readers and replacement.
+    let handle = unsafe {
+        CreateFileW(
+            name.as_ptr(),
+            GENERIC_READ | FILE_APPEND_DATA,
+            FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
+            &security.attributes(),
+            OPEN_ALWAYS,
+            FILE_FLAG_OPEN_REPARSE_POINT,
+            ptr::null_mut(),
+        )
+    };
+    if handle == INVALID_HANDLE_VALUE {
+        return Err(io::Error::last_os_error());
+    }
+    // SAFETY: successful CreateFileW transfers one owned handle.
+    let file = unsafe { File::from_raw_handle(handle) };
+    if file.metadata()?.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 {
+        return Err(denied());
+    }
+    Ok(file)
+}
diff --git a/platform/host-runtime/src/logs/tests.rs b/platform/host-runtime/src/logs/tests.rs
new file mode 100644
index 0000000000..02b3b3315e
--- /dev/null
+++ b/platform/host-runtime/src/logs/tests.rs
@@ -0,0 +1,283 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use super::*;
+use std::io::Write;
+use tokio::sync::mpsc;
+
+fn fixture_source(temp: &tempfile::TempDir, digit: char) -> Source {
+    Source::for_root(
+        &digit.to_string().repeat(64),
+        &RootNamespaces {
+            ownership: temp.path().join("owners"),
+            control: temp.path().join("control"),
+        },
+    )
+    .unwrap()
+}
+fn tail() -> HostLogRequest {
+    HostLogRequest {
+        position: HostLogPosition::Tail,
+    }
+}
+fn after(cursor: HostLogCursor) -> HostLogRequest {
+    HostLogRequest {
+        position: HostLogPosition::After { cursor },
+    }
+}
+fn data(page: HostLogPage) -> (HostLogCursor, Vec<u8>, Option<HostLogReset>, bool, bool) {
+    match page {
+        HostLogPage::Data {
+            cursor,
+            bytes,
+            reset,
+            truncated,
+            has_more,
+        } => (
+            cursor,
+            STANDARD.decode(bytes).unwrap(),
+            reset,
+            truncated,
+            has_more,
+        ),
+        HostLogPage::Missing => panic!("expected captured file"),
+    }
+}
+
+#[tokio::test]
+async fn bounded_binary_tail_and_forward_cursor_preserve_exact_bytes() {
+    let temp = tempfile::tempdir().unwrap();
+    let source = fixture_source(&temp, 'a');
+    assert_eq!(source.read(tail()).await.unwrap(), HostLogPage::Missing);
+    assert!(
+        !source.path().parent().unwrap().exists(),
+        "read must not create a directory"
+    );
+    let mut writer = source.open_capture().unwrap();
+    let (_, empty, reset, truncated, more) = data(source.read(tail()).await.unwrap());
+    assert!(empty.is_empty());
+    assert_eq!((reset, truncated, more), (None, false, false));
+    let initial: Vec<_> = (0..RAW_LIMIT + 17).map(|i| i as u8).collect();
+    writer.write_all(&initial).unwrap();
+    let page = source.read(tail()).await.unwrap();
+    assert!(serde_json::to_vec(&page).unwrap().len() <= 48 * 1024);
+    let (cursor, bytes, reset, truncated, more) = data(page);
+    assert_eq!(bytes, initial[17..]);
+    assert_eq!((reset, truncated, more), (None, true, false));
+    let later: Vec<_> = (0..RAW_LIMIT + 9).map(|i| (i % 251) as u8).collect();
+    writer.write_all(&later).unwrap();
+    let serialized = serde_json::to_string(&after(cursor)).unwrap();
+    let request = serde_json::from_str(&serialized).unwrap();
+    let (cursor, bytes, reset, truncated, more) = data(source.read(request).await.unwrap());
+    assert_eq!(bytes, later[..RAW_LIMIT]);
+    assert_eq!((reset, truncated, more), (None, false, true));
+    let (cursor, bytes, reset, truncated, more) = data(source.read(after(cursor)).await.unwrap());
+    assert_eq!(bytes, later[RAW_LIMIT..]);
+    assert_eq!((reset, truncated, more), (None, false, false));
+    // Deliberately split a UTF-8 sequence and include invalid UTF-8. Cursors
+    // address raw bytes, not independently decoded text chunks.
+    writer.write_all(&[0xe4, 0xb8]).unwrap();
+    let (cursor, bytes, ..) = data(source.read(after(cursor)).await.unwrap());
+    assert_eq!(bytes, [0xe4, 0xb8]);
+    writer.write_all(&[0xad, 0xff, 0]).unwrap();
+    let (_, bytes, ..) = data(source.read(after(cursor)).await.unwrap());
+    assert_eq!(bytes, [0xad, 0xff, 0]);
+}
+
+#[tokio::test]
+async fn replacement_shrink_and_foreign_or_invalid_requests_are_explicit() {
+    let temp = tempfile::tempdir().unwrap();
+    let source = fixture_source(&temp, 'b');
+    source
+        .open_capture()
+        .unwrap()
+        .write_all(b"original-long")
+        .unwrap();
+    let (cursor, ..) = data(source.read(tail()).await.unwrap());
+    let parked = source.path().with_extension("old");
+    std::fs::rename(source.path(), &parked).unwrap();
+    assert_eq!(
+        source.read(after(cursor.clone())).await.unwrap(),
+        HostLogPage::Missing
+    );
+    source
+        .open_capture()
+        .unwrap()
+        .write_all(b"replacement")
+        .unwrap();
+    let (cursor, bytes, reset, _, _) = data(source.read(after(cursor)).await.unwrap());
+    assert_eq!(bytes, b"replacement");
+    assert_eq!(reset, Some(HostLogReset::Replaced));
+    let mut writer = std::fs::OpenOptions::new()
+        .write(true)
+        .open(source.path())
+        .unwrap();
+    writer.set_len(0).unwrap();
+    writer.write_all(b"new").unwrap();
+    let (cursor, bytes, reset, _, _) = data(source.read(after(cursor)).await.unwrap());
+    assert_eq!(bytes, b"new");
+    assert_eq!(reset, Some(HostLogReset::Truncated));
+    let other = fixture_source(&temp, 'c');
+    assert_eq!(
+        other.read(after(cursor)).await.unwrap_err().kind(),
+        io::ErrorKind::InvalidInput
+    );
+    for input in ["bad".to_owned(), "a".repeat(513)] {
+        assert!(input.parse::<HostLogCursor>().is_err());
+    }
+    assert!(
+        serde_json::from_value::<HostLogRequest>(
+            serde_json::json!({"position":{"kind":"tail"},"path":"/tmp/secret"})
+        )
+        .is_err()
+    );
+    assert!(
+        Source::for_root(
+            "../other",
+            &RootNamespaces {
+                ownership: temp.path().into(),
+                control: temp.path().into()
+            }
+        )
+        .is_err()
+    );
+}
+
+#[tokio::test]
+async fn watch_retains_cursor_across_missing_and_settles_idle_cancellation() {
+    let temp = tempfile::tempdir().unwrap();
+    let source = fixture_source(&temp, 'd');
+    source.open_capture().unwrap().write_all(b"before").unwrap();
+    let (cursor, ..) = data(source.read(tail()).await.unwrap());
+    let parked = source.path().with_extension("parked");
+    std::fs::rename(source.path(), &parked).unwrap();
+    let cancel = CancellationToken::new();
+    let watching = source.clone();
+    let cancellation = cancel.clone();
+    // A bounded delivery lane proves watch does not rely on unbounded buffering.
+    let (send, mut receive) = mpsc::channel(1);
+    let task = tokio::spawn(async move {
+        watching
+            .watch(after(cursor), cancellation, |page| {
+                let send = send.clone();
+                async move { send.send(page).await.map_err(io::Error::other) }
+            })
+            .await
+    });
+    let first = tokio::time::timeout(Duration::from_secs(3), receive.recv())
+        .await
+        .unwrap()
+        .unwrap();
+    assert_eq!(first, HostLogPage::Missing);
+    let mut writer = std::fs::OpenOptions::new()
+        .append(true)
+        .open(&parked)
+        .unwrap();
+    writer.write_all(b"after").unwrap();
+    std::fs::rename(&parked, source.path()).unwrap();
+    let next = tokio::time::timeout(Duration::from_secs(3), receive.recv())
+        .await
+        .unwrap()
+        .unwrap();
+    let (_, bytes, reset, _, _) = data(next);
+    assert_eq!(
+        bytes, b"after",
+        "missing must not discard the original byte cursor"
+    );
+    assert_eq!(reset, None);
+    cancel.cancel();
+    tokio::time::timeout(Duration::from_secs(3), task)
+        .await
+        .unwrap()
+        .unwrap()
+        .unwrap();
+    assert!(receive.recv().await.is_none());
+}
+
+#[tokio::test]
+async fn source_does_not_keep_root_ownership_alive() {
+    let temp = tempfile::tempdir().unwrap();
+    let namespaces = RootNamespaces {
+        ownership: temp.path().join("owners"),
+        control: temp.path().join("control"),
+    };
+    let root_path = temp.path().join("root");
+    let owner = RootOwner::create(&root_path, &namespaces).unwrap();
+    let source = Source::from_owner(&owner).unwrap();
+    drop(owner);
+    let reopened = RootOwner::open(&root_path, &namespaces).unwrap();
+    source
+        .open_capture()
+        .unwrap()
+        .write_all(b"still readable")
+        .unwrap();
+    assert_eq!(
+        data(source.read(tail()).await.unwrap()).1,
+        b"still readable"
+    );
+    drop(reopened);
+}
+
+#[cfg(unix)]
+#[tokio::test]
+async fn reads_reject_nonprivate_files_links_and_symlink_directories() {
+    use std::os::unix::fs::{PermissionsExt, symlink};
+    let temp = tempfile::tempdir().unwrap();
+    let source = fixture_source(&temp, 'e');
+    source
+        .open_capture()
+        .unwrap()
+        .write_all(b"private")
+        .unwrap();
+    std::fs::set_permissions(source.path(), std::fs::Permissions::from_mode(0o644)).unwrap();
+    assert!(source.read(tail()).await.is_err());
+    assert_eq!(
+        std::fs::metadata(source.path())
+            .unwrap()
+            .permissions()
+            .mode()
+            & 0o777,
+        0o644,
+        "reader must not chmod"
+    );
+    std::fs::set_permissions(source.path(), std::fs::Permissions::from_mode(0o600)).unwrap();
+    let link = source.path().with_extension("link");
+    std::fs::hard_link(source.path(), &link).unwrap();
+    assert!(source.read(tail()).await.is_err());
+    assert!(source.open_capture().is_err());
+    std::fs::remove_file(&link).unwrap();
+    std::fs::rename(source.path(), &link).unwrap();
+    symlink(&link, source.path()).unwrap();
+    assert!(source.read(tail()).await.is_err());
+    assert!(source.open_capture().is_err());
+    std::fs::remove_file(source.path()).unwrap();
+    std::fs::rename(&link, source.path()).unwrap();
+    let parent = source.path().parent().unwrap();
+    std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o755)).unwrap();
+    assert!(source.read(tail()).await.is_err());
+    assert_eq!(
+        std::fs::metadata(parent).unwrap().permissions().mode() & 0o777,
+        0o755
+    );
+    std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700)).unwrap();
+    let moved = parent.with_extension("old");
+    std::fs::rename(parent, &moved).unwrap();
+    symlink(&moved, parent).unwrap();
+    assert!(source.read(tail()).await.is_err());
+}
diff --git a/platform/js-runtime/src/plugin/engine.rs b/platform/js-runtime/src/plugin/engine.rs
index a928c9b69f..176a223f2f 100644
--- a/platform/js-runtime/src/plugin/engine.rs
+++ b/platform/js-runtime/src/plugin/engine.rs
@@ -61,8 +61,12 @@ fn runtime_base(health: &Arc<Health>, limits: &Limits, realm: super::Realm) -> R
     runtime
         .execute_script(
             "maka-plugin-globals",
-            r#"for (const [path, names] of [
+            r#"const { DOMException } = Deno.core.loadExtScript('ext:deno_web/01_dom_exception.js');
+            Deno.core.registerErrorBuilder('DOMExceptionInvalidCharacterError',
+                message => new DOMException(message, 'InvalidCharacterError'));
+            for (const [path, names] of [
                 ['ext:deno_web/08_text_encoding.js', ['TextEncoder', 'TextDecoder']],
+                ['ext:deno_web/05_base64.js', ['atob']],
                 ['ext:deno_web/00_url.js', ['URL', 'URLSearchParams']],
             ]) {
                 const exports = Deno.core.loadExtScript(path);
diff --git a/platform/js-runtime/tests/integration/plugin_vm/captured.rs b/platform/js-runtime/tests/integration/plugin_vm/captured.rs
index fb0bcd88da..f4d054fbf9 100644
--- a/platform/js-runtime/tests/integration/plugin_vm/captured.rs
+++ b/platform/js-runtime/tests/integration/plugin_vm/captured.rs
@@ -340,3 +340,26 @@ async fn pure_worker_has_no_ambient_host_ops() {
         vm.shutdown().await;
     }).await.unwrap();
 }
+
+#[tokio::test]
+async fn pure_worker_decodes_binary_base64_without_ambient_host_capabilities() {
+    tokio::time::timeout(Duration::from_secs(10), async {
+        let vm = Vm::pure(Limits::default()).unwrap();
+        let module = vm.load("codec.mjs".into(), r#"
+            export function decode() {
+                const bytes = Uint8Array.from(atob('AP8b5Lit5paH8J+Zgg=='), c => c.charCodeAt(0));
+                let invalid;
+                try { atob('invalid!'); } catch (error) { invalid = error.name; }
+                return {bytes: Array.from(bytes), text: new TextDecoder().decode(bytes.subarray(3)),
+                    invalid, deno: typeof Deno, buffer: typeof Buffer};
+            }
+        "#.into()).unwrap();
+        let result = module.call(vec!["decode".into()], vec![]).await;
+        module.close().await.unwrap();
+        vm.shutdown().await;
+        assert_eq!(result.unwrap(), json!({
+            "bytes": [0, 255, 27, 228, 184, 173, 230, 150, 135, 240, 159, 153, 130],
+            "text": "中文🙂", "invalid": "InvalidCharacterError", "deno": "undefined", "buffer": "undefined"
+        }));
+    }).await.unwrap();
+}
diff --git a/platform/process/src/detached.rs b/platform/process/src/detached.rs
index ae05724b13..a063b12a85 100644
--- a/platform/process/src/detached.rs
+++ b/platform/process/src/detached.rs
@@ -28,14 +28,18 @@ pub use tokio::process::Child;
 pub use windows::{Child, spawn};
 
 #[cfg(unix)]
-pub async fn spawn(executable: &std::path::Path, args: &[&str]) -> std::io::Result<Child> {
+pub async fn spawn(
+    executable: &std::path::Path,
+    args: &[&str],
+    stderr: Option<std::fs::File>,
+) -> std::io::Result<Child> {
     use std::process::Stdio;
     let mut command = tokio::process::Command::new(executable);
     command
         .args(args)
         .stdin(Stdio::piped())
         .stdout(Stdio::null())
-        .stderr(Stdio::null());
+        .stderr(stderr.map(Stdio::from).unwrap_or_else(Stdio::null));
     // SAFETY: setsid is async-signal-safe and touches no Rust state.
     unsafe {
         command.pre_exec(|| {
diff --git a/platform/process/src/detached/windows.rs b/platform/process/src/detached/windows.rs
index ed797c2c2b..5128584e04 100644
--- a/platform/process/src/detached/windows.rs
+++ b/platform/process/src/detached/windows.rs
@@ -22,7 +22,7 @@ use crate::{
     windows::{attributes::Attributes, checked, owned, pipe, try_wait_process, wait_process},
 };
 use std::{
-    fs::OpenOptions,
+    fs::{File, OpenOptions},
     io,
     mem::size_of,
     os::windows::io::{AsRawHandle, OwnedHandle},
@@ -32,7 +32,9 @@ use std::{
 };
 use tokio::net::windows::named_pipe::NamedPipeServer;
 use windows_sys::Win32::{
-    Foundation::{HANDLE_FLAG_INHERIT, SetHandleInformation},
+    Foundation::{
+        DUPLICATE_SAME_ACCESS, DuplicateHandle, HANDLE_FLAG_INHERIT, SetHandleInformation,
+    },
     System::Threading::*,
 };
 
@@ -65,7 +67,7 @@ impl Child {
     }
 }
 
-pub async fn spawn(executable: &Path, args: &[&str]) -> io::Result<Child> {
+pub async fn spawn(executable: &Path, args: &[&str], stderr: Option<File>) -> io::Result<Child> {
     if !executable.is_absolute() {
         return Err(io::Error::other("detached executable must be absolute"));
     }
@@ -81,7 +83,26 @@ pub async fn spawn(executable: &Path, args: &[&str]) -> io::Result<Child> {
     let mut line = wide(Path::new(&arguments))?;
     let (stdin, reader) = pipe::pty_input().await?;
     let null = OpenOptions::new().read(true).write(true).open("NUL")?;
-    let handles = [reader.as_raw_handle(), null.as_raw_handle()];
+    // Duplicate rather than change inheritance on the caller's capture handle.
+    let mut duplicate = ptr::null_mut();
+    // SAFETY: the selected owned file and current process remain live through duplication.
+    unsafe {
+        checked(DuplicateHandle(
+            GetCurrentProcess(),
+            stderr.as_ref().unwrap_or(&null).as_raw_handle(),
+            GetCurrentProcess(),
+            &mut duplicate,
+            0,
+            1,
+            DUPLICATE_SAME_ACCESS,
+        ))?;
+    }
+    let capture = owned(duplicate)?;
+    let handles = [
+        reader.as_raw_handle(),
+        null.as_raw_handle(),
+        capture.as_raw_handle(),
+    ];
     // SAFETY: owned pipe/NUL handles, inherited only through the explicit list.
     for handle in handles {
         unsafe {
@@ -98,7 +119,7 @@ pub async fn spawn(executable: &Path, args: &[&str]) -> io::Result<Child> {
     startup.StartupInfo.dwFlags = STARTF_USESTDHANDLES;
     startup.StartupInfo.hStdInput = handles[0];
     startup.StartupInfo.hStdOutput = handles[1];
-    startup.StartupInfo.hStdError = handles[1];
+    startup.StartupInfo.hStdError = handles[2];
     startup.lpAttributeList = attributes.as_ptr();
     let mut process = PROCESS_INFORMATION::default();
     // SAFETY: all buffers/handles outlive this call; environment and cwd inherit.
diff --git a/plugins/agent/agent-screen/actions.ts b/plugins/agent/agent-screen/actions.ts
index dc020e17b9..c3c91c4f68 100644
--- a/plugins/agent/agent-screen/actions.ts
+++ b/plugins/agent/agent-screen/actions.ts
@@ -52,6 +52,7 @@ import { toolCards } from '../../shared/tool-cards.js';
 import { readingKey } from './state.js';
 import { newGoalState, watchGoal } from './goal.js';
 import { newPlanState, watchPlan } from './plan.js';
+import { readHostLogs } from './host-logs.js';
 import { newDiagnostics, readDiagnostics } from './diagnostics.js';
 import { newGraphState, watchGraph } from './graph.js';
 import { messages } from './messages.js';
@@ -379,6 +380,7 @@ export function follow(state: State, actions: Action[]) {
 }
 
 export function select(state: State, actions: Action[], session: AgentSession | null) {
+  const hostOpen = state.diagnostics.source === 'host' && state.inspector === 'diagnostics';
   const previous = state.visits.find((visit) => visit.id === state.selected?.id);
   if (previous)
     Object.assign(previous, {
@@ -392,7 +394,7 @@ export function select(state: State, actions: Action[], session: AgentSession |
       landing: state.landing,
       searchOpen: state.searchOpen,
       browse: state.browse,
-      inspector: state.inspector,
+      inspector: hostOpen ? null : state.inspector,
     });
   let visit = session
     ? state.visits.find(
@@ -426,7 +428,7 @@ export function select(state: State, actions: Action[], session: AgentSession |
     ].slice(-3);
   state.searchOpen = visit?.searchOpen ?? false;
   state.browse = visit?.browse ?? null;
-  state.inspector = visit?.inspector ?? null;
+  state.inspector = hostOpen ? 'diagnostics' : (visit?.inspector ?? null);
   state.recallAnchor = null;
   state.selected = session;
   state.backendReady = false;
@@ -435,7 +437,11 @@ export function select(state: State, actions: Action[], session: AgentSession |
   state.executor = null;
   state.modelRefreshing = false;
   state.epoch++;
-  state.diagnostics = newDiagnostics(session?.id, state.epoch);
+  state.diagnostics = {
+    ...newDiagnostics(session?.id, state.epoch),
+    source: state.diagnostics.source,
+    host: state.diagnostics.host,
+  };
   state.graph = newGraphState(state.graph.enabled);
   if (session) watchGraph(state);
   state.plan = newPlanState();
@@ -730,7 +736,7 @@ export function rootCommands(
       id: 'diagnostics-open',
       label: text.diagnosticsTitleLabel,
       hint: '',
-      disabled: !route || !state.selected,
+      disabled: !route || (!state.selected && !state.hostLogsEnabled),
     },
     {
       kind: 'compact-open',
@@ -1104,7 +1110,9 @@ export function rootCommand(
   if (kind === 'diagnostics-open') {
     if (target === 'tui') state.inspector = 'diagnostics';
     else state.dialog = 'diagnostics';
-    readDiagnostics(state, actions);
+    if (!state.selected && state.hostLogsEnabled) state.diagnostics.source = 'host';
+    if (state.diagnostics.source === 'host' && state.hostLogsEnabled) readHostLogs(state, actions);
+    else readDiagnostics(state, actions);
   } else if (kind === 'compact-open') openCompact(state, actions);
   else if (kind === 'outputs-open') {
     state.inspector = 'outputs';
diff --git a/plugins/agent/agent-screen/desktop.ts b/plugins/agent/agent-screen/desktop.ts
index 92d60b7655..ee42c36c6c 100644
--- a/plugins/agent/agent-screen/desktop.ts
+++ b/plugins/agent/agent-screen/desktop.ts
@@ -30,6 +30,7 @@ export function activate(configuration: {
   web?: boolean;
   jev?: boolean;
   graphs?: boolean;
+  hostLogs?: boolean;
 }) {
   component = agentScreen(
     presentations.desktop,
@@ -41,6 +42,7 @@ export function activate(configuration: {
     configuration.web === true,
     configuration.jev === true,
     configuration.graphs === true,
+    configuration.hostLogs === true,
   );
   return {
     AgentScreen: { kind: 'component', contract: agentScreenContract, component: ['component'] },
diff --git a/plugins/agent/agent-screen/diagnostics.ts b/plugins/agent/agent-screen/diagnostics.ts
index f5076e906e..5f9bd7ed27 100644
--- a/plugins/agent/agent-screen/diagnostics.ts
+++ b/plugins/agent/agent-screen/diagnostics.ts
@@ -29,8 +29,18 @@ import { type Presentation, type Node, fill } from '../../shared/view.js';
 import { copy, type Copy } from './locale.js';
 import { intent } from './actions.js';
 import type { State } from './state.js';
+import {
+  newHostLogs,
+  hostLogsView,
+  readHostLogs,
+  stopHostLogs,
+  hostLogEvent,
+  type HostLogsState,
+} from './host-logs.js';
 
 export interface DiagnosticsState {
+  source: 'conversation' | 'host';
+  host: HostLogsState;
   watch: { key: string; input: { sessionId: string } } | null;
   revision: number;
   through: number;
@@ -49,6 +59,8 @@ export type DiagnosticsRequest = {
 };
 export function newDiagnostics(sessionId?: string, epoch = 0): DiagnosticsState {
   return {
+    source: 'conversation',
+    host: newHostLogs(),
     watch: sessionId ? { key: 'diagnostics-watch-' + epoch, input: { sessionId } } : null,
     revision: 0,
     through: 0,
@@ -61,14 +73,17 @@ export function newDiagnostics(sessionId?: string, epoch = 0): DiagnosticsState
   };
 }
 function open(state: State) {
-  return state.inspector === 'diagnostics' || state.dialog === 'diagnostics';
+  return (
+    state.diagnostics.source === 'conversation' &&
+    (state.inspector === 'diagnostics' || state.dialog === 'diagnostics')
+  );
 }
 export function readDiagnostics(
   state: State,
   actions: Action[],
   position: AgentDiagnostics['position'] = { kind: 'latest' },
 ) {
-  if (!state.selected) return;
+  if (!state.selected || state.diagnostics.source !== 'conversation') return;
   if (!state.diagnostics.watch)
     state.diagnostics.watch = {
       key: `diagnostics-watch-${state.epoch}-${++state.serial}`,
@@ -119,9 +134,18 @@ export function diagnosticResult(
   diagnostics.failed = false;
 }
 export function diagnosticEvent(state: State, actions: Action[], key: string): boolean {
+  if (hostLogEvent(state, actions, key)) return true;
   if (!key.startsWith('diagnostic')) return false;
   const diagnostics = state.diagnostics;
-  if (key === 'diagnostics-close') {
+  if (key === 'diagnostics-source-host' && state.hostLogsEnabled) {
+    diagnostics.source = 'host';
+    readHostLogs(state, actions);
+  } else if (key === 'diagnostics-source-conversation' && state.selected) {
+    stopHostLogs(state);
+    diagnostics.source = 'conversation';
+    readDiagnostics(state, actions);
+  } else if (key === 'diagnostics-close') {
+    stopHostLogs(state);
     if (state.dialog === 'diagnostics') state.dialog = null;
     if (state.inspector === 'diagnostics') state.inspector = null;
   } else if (key === 'diagnostics-back') diagnostics.selected = null;
@@ -257,94 +281,117 @@ export function diagnosticsView<N extends Node>(
         ],
         { row: true, gap: 1 },
       ),
-      ...(diagnostics.failed
-        ? [ui.text('diagnostics-error', text.recoveryRefreshFailed, 13, theme.warning)]
-        : []),
-      ...(selected
+      ...(state.hostLogsEnabled
         ? [
-            ui.button('diagnostics-back', text.back),
-            ui.text('diagnostic-detail-source', source(selected, text), 15, theme.text, 550),
-            ui.text(
-              'diagnostic-detail-cause',
-              cause(selected, text),
-              14,
-              failureColor(selected, theme),
-            ),
-            ui.text(
-              'diagnostic-detail-time',
-              new Date(selected.recordedAt).toISOString(),
-              12,
-              theme.muted,
-            ),
-            ui.text(
-              'diagnostic-detail-invocation',
-              selected.detail.invocation.invocation_id,
-              12,
-              theme.muted,
-            ),
-            ui.text('diagnostic-detail-event', selected.eventId, 12, theme.muted),
-            ...(selected.detail.kind === 'model_step'
-              ? [ui.text('diagnostic-detail-step', selected.detail.stepId, 12, theme.muted)]
-              : []),
-          ]
-        : [
-            presentation.reading(
-              'diagnostics-reading',
-              {
-                command: null,
-                content: ui.box(
-                  'diagnostics-records',
-                  diagnostics.page
-                    ? diagnostics.page.entries.length
-                      ? diagnostics.page.entries.map((record) => {
-                          const tone = failureColor(record, theme);
-                          return ui.box(
-                            'diagnostic-' + record.sequence,
-                            [
-                              ui.button('diagnostic-open-' + record.sequence, source(record, text)),
-                              ui.text(
-                                'diagnostic-cause-' + record.sequence,
-                                cause(record, text),
-                                13,
-                                tone,
-                              ),
-                              ui.text(
-                                'diagnostic-time-' + record.sequence,
-                                new Date(record.recordedAt).toISOString(),
-                                12,
-                                theme.muted,
-                              ),
-                            ],
-                            { gap: 0 },
-                          );
-                        })
-                      : [ui.text('diagnostics-empty', text.diagnosticsEmpty, 13, theme.muted)]
-                    : [ui.text('diagnostics-loading', text.loading, 13, theme.muted)],
-                  {
-                    gap: 1,
-                    scroll: 'manual',
-                    terminal: { grow: 1 },
-                    style: { width: fill, height: fill },
-                  },
-                ),
-              },
-              {},
-            ),
             ui.box(
-              'diagnostics-controls',
+              'diagnostics-sources',
               [
                 ui.button(
-                  'diagnostics-refresh',
-                  diagnostics.position.kind === 'before' ? text.latest : text.check,
-                ),
-                ui.button(
-                  'diagnostics-older',
-                  text.older,
-                  diagnostics.loading !== null || diagnostics.page?.nextBefore == null,
+                  'diagnostics-source-conversation',
+                  text.diagnosticsConversation,
+                  !state.selected,
                 ),
+                ui.button('diagnostics-source-host', text.hostLogsLabel),
               ],
               { row: true, gap: 1 },
             ),
+          ]
+        : []),
+      ...(diagnostics.source === 'host' && state.hostLogsEnabled
+        ? [hostLogsView(presentation, state, theme, locale)]
+        : [
+            ...(diagnostics.failed
+              ? [ui.text('diagnostics-error', text.recoveryRefreshFailed, 13, theme.warning)]
+              : []),
+            ...(selected
+              ? [
+                  ui.button('diagnostics-back', text.back),
+                  ui.text('diagnostic-detail-source', source(selected, text), 15, theme.text, 550),
+                  ui.text(
+                    'diagnostic-detail-cause',
+                    cause(selected, text),
+                    14,
+                    failureColor(selected, theme),
+                  ),
+                  ui.text(
+                    'diagnostic-detail-time',
+                    new Date(selected.recordedAt).toISOString(),
+                    12,
+                    theme.muted,
+                  ),
+                  ui.text(
+                    'diagnostic-detail-invocation',
+                    selected.detail.invocation.invocation_id,
+                    12,
+                    theme.muted,
+                  ),
+                  ui.text('diagnostic-detail-event', selected.eventId, 12, theme.muted),
+                  ...(selected.detail.kind === 'model_step'
+                    ? [ui.text('diagnostic-detail-step', selected.detail.stepId, 12, theme.muted)]
+                    : []),
+                ]
+              : [
+                  presentation.reading(
+                    'diagnostics-reading',
+                    {
+                      command: null,
+                      content: ui.box(
+                        'diagnostics-records',
+                        diagnostics.page
+                          ? diagnostics.page.entries.length
+                            ? diagnostics.page.entries.map((record) => {
+                                const tone = failureColor(record, theme);
+                                return ui.box(
+                                  'diagnostic-' + record.sequence,
+                                  [
+                                    ui.button(
+                                      'diagnostic-open-' + record.sequence,
+                                      source(record, text),
+                                    ),
+                                    ui.text(
+                                      'diagnostic-cause-' + record.sequence,
+                                      cause(record, text),
+                                      13,
+                                      tone,
+                                    ),
+                                    ui.text(
+                                      'diagnostic-time-' + record.sequence,
+                                      new Date(record.recordedAt).toISOString(),
+                                      12,
+                                      theme.muted,
+                                    ),
+                                  ],
+                                  { gap: 0 },
+                                );
+                              })
+                            : [ui.text('diagnostics-empty', text.diagnosticsEmpty, 13, theme.muted)]
+                          : [ui.text('diagnostics-loading', text.loading, 13, theme.muted)],
+                        {
+                          gap: 1,
+                          scroll: 'manual',
+                          terminal: { grow: 1 },
+                          style: { width: fill, height: fill },
+                        },
+                      ),
+                    },
+                    {},
+                  ),
+                  ui.box(
+                    'diagnostics-controls',
+                    [
+                      ui.button(
+                        'diagnostics-refresh',
+                        diagnostics.position.kind === 'before' ? text.latest : text.check,
+                      ),
+                      ui.button(
+                        'diagnostics-older',
+                        text.older,
+                        diagnostics.loading !== null || diagnostics.page?.nextBefore == null,
+                      ),
+                    ],
+                    { row: true, gap: 1 },
+                  ),
+                ]),
           ]),
     ],
     {
diff --git a/plugins/agent/agent-screen/events.ts b/plugins/agent/agent-screen/events.ts
index ec21e80ec9..531906e56a 100644
--- a/plugins/agent/agent-screen/events.ts
+++ b/plugins/agent/agent-screen/events.ts
@@ -31,6 +31,7 @@ import type {
   Emission,
 } from '../../../packages/plugin-sdk/src/index.js';
 import { appearanceEvent, effectiveLocale } from '../../shared/appearance-editor.js';
+import { hostLogUpdate } from './host-logs.js';
 import { diagnosticChange } from './diagnostics.js';
 import { observeBookmark, retryBookmark } from '../../shared/bookmark.js';
 import type { AgentScreenProps, Theme } from '../../shared/contracts.js';
@@ -202,7 +203,15 @@ export function handleEvent<N extends Node>(
     return frame(presentation, configuredLocale, state, values, actions);
   if (input.kind === 'action') {
     const outcome = input.event as ActionOutcome;
-    if (state.diagnostics.watch?.key === input.key) {
+    if (state.diagnostics.host.watch?.key === input.key) {
+      if (outcome.status === 'updated')
+        hostLogUpdate(
+          state,
+          outcome.value as import('../../../packages/plugin-sdk/src/index.js').HostLogPage,
+        );
+      else if (outcome.status === 'failed' || outcome.status === 'not_accepted')
+        hostLogUpdate(state, null);
+    } else if (state.diagnostics.watch?.key === input.key) {
       if (outcome.status === 'updated')
         diagnosticChange(
           state,
diff --git a/plugins/agent/agent-screen/frame.ts b/plugins/agent/agent-screen/frame.ts
index cb635348ed..e441a22f23 100644
--- a/plugins/agent/agent-screen/frame.ts
+++ b/plugins/agent/agent-screen/frame.ts
@@ -50,6 +50,7 @@ import {
   type Presentation,
 } from '../../shared/view.js';
 
+import { hostLogsOpen, stopHostLogs } from './host-logs.js';
 import { diagnosticsView } from './diagnostics.js';
 import { checklistView } from './checklist-view.js';
 import { goalView } from './goal.js';
@@ -83,6 +84,11 @@ export function frame<N extends Node>(
   actions: Action[] = [],
   events: Emission[] = [],
 ) {
+  if (
+    !hostLogsOpen(state) &&
+    (state.diagnostics.host.watch || state.diagnostics.host.loading !== null)
+  )
+    stopHostLogs(state);
   const target = presentation.target;
   const currentPage = state.pages.at(-1);
   if (currentPage && !state.openedPages.includes(currentPage)) state.openedPages.push(currentPage);
@@ -248,7 +254,9 @@ export function frame<N extends Node>(
           ]),
   ];
   const inspectorOpen =
-    conversationRoute(state) && !!state.selected && (state.inspector !== null || inlineInteraction);
+    conversationRoute(state) &&
+    ((!!state.selected && (state.inspector !== null || inlineInteraction)) ||
+      (hostLogsOpen(state) && state.inspector === 'diagnostics'));
   const inspectorSlots = [
     diagnosticsView(presentation, state, theme, locale),
     graphView(presentation, state, theme, locale),
@@ -706,6 +714,18 @@ export function frame<N extends Node>(
   out.actions = actions;
   out.events = events;
   out.subscriptions = [
+    ...(hostLogsOpen(state) && state.diagnostics.host.watch
+      ? [
+          {
+            key: state.diagnostics.host.watch.key,
+            input: state.diagnostics.host.watch.input,
+            binding: 'hostLogs',
+            member: 0,
+            method: 'watch',
+            resources: {},
+          },
+        ]
+      : []),
     ...(state.diagnostics.watch
       ? [
           {
diff --git a/plugins/agent/agent-screen/header-view.ts b/plugins/agent/agent-screen/header-view.ts
index f9cd2a7018..76ded3de52 100644
--- a/plugins/agent/agent-screen/header-view.ts
+++ b/plugins/agent/agent-screen/header-view.ts
@@ -180,7 +180,9 @@ export function headerView<N extends Node>(
                         ui.button(
                           key!,
                           icon!,
-                          key !== 'palette-open' && !state.selected,
+                          key !== 'palette-open' &&
+                            !state.selected &&
+                            !(key === 'diagnostics-open' && state.hostLogsEnabled),
                           false,
                           { background: 0 },
                           undefined,
diff --git a/plugins/agent/agent-screen/host-logs.ts b/plugins/agent/agent-screen/host-logs.ts
new file mode 100644
index 0000000000..21049a3c25
--- /dev/null
+++ b/plugins/agent/agent-screen/host-logs.ts
@@ -0,0 +1,264 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import type {
+  Action,
+  HostLogRequest,
+  HostLogPage,
+} from '../../../packages/plugin-sdk/src/index.js';
+import type { Theme } from '../../shared/contracts.js';
+import { type Node, type Presentation, fill } from '../../shared/view.js';
+import { copy } from './locale.js';
+import { intent, conversationRoute } from './actions.js';
+import type { State } from './state.js';
+
+const WINDOW_BYTES = 64 * 1024;
+export interface HostLogsState {
+  generation: number;
+  cursor: string | null;
+  bytes: number[];
+  status: 'unread' | 'missing' | 'data';
+  omitted: boolean;
+  reset: Extract<HostLogPage, { kind: 'data' }>['reset'];
+  loading: number | null;
+  failed: boolean;
+  watch: { key: string; input: HostLogRequest; generation: number } | null;
+}
+export type HostLogRead = {
+  kind: 'hostLogRead';
+  generation: number;
+  attempt: number;
+  position: HostLogRequest['position'];
+};
+export function newHostLogs(): HostLogsState {
+  return {
+    generation: 0,
+    cursor: null,
+    bytes: [],
+    status: 'unread',
+    omitted: false,
+    reset: null,
+    loading: null,
+    failed: false,
+    watch: null,
+  };
+}
+export function hostLogsOpen(state: State): boolean {
+  return (
+    conversationRoute(state) &&
+    state.hostLogsEnabled &&
+    state.diagnostics.source === 'host' &&
+    (state.inspector === 'diagnostics' || state.dialog === 'diagnostics')
+  );
+}
+export function stopHostLogs(state: State) {
+  const host = state.diagnostics.host;
+  host.generation++;
+  host.watch = null;
+  host.loading = null;
+}
+export function readHostLogs(
+  state: State,
+  actions: Action[],
+  position: HostLogRequest['position'] = { kind: 'tail' },
+) {
+  if (!state.hostLogsEnabled) return;
+  if (position.kind === 'tail') stopHostLogs(state);
+  const host = state.diagnostics.host,
+    attempt = ++state.serial;
+  host.loading = attempt;
+  host.failed = false;
+  intent(
+    state,
+    actions,
+    { kind: 'hostLogRead', generation: host.generation, attempt, position },
+    'hostLogs',
+    'read',
+    { position } satisfies HostLogRequest,
+  );
+}
+function accept(host: HostLogsState, page: HostLogPage, tail: boolean): boolean {
+  if (page.kind === 'missing') {
+    if (tail) {
+      host.bytes = [];
+      host.cursor = null;
+      host.omitted = false;
+      host.reset = null;
+    }
+    host.status = 'missing';
+    return true;
+  }
+  let bytes: number[];
+  try {
+    bytes = Array.from(Uint8Array.from(atob(page.bytes), (character) => character.charCodeAt(0)));
+  } catch {
+    return false;
+  }
+  if (!tail && !page.reset && page.cursor === host.cursor) {
+    host.status = 'data';
+    return true;
+  }
+  if (tail || page.reset) {
+    host.bytes = [];
+    host.omitted = false;
+    host.reset = page.reset;
+  }
+  host.bytes.push(...bytes);
+  host.omitted ||= page.truncated;
+  if (host.bytes.length > WINDOW_BYTES) {
+    host.bytes = host.bytes.slice(-WINDOW_BYTES);
+    host.omitted = true;
+  }
+  // Invalid UTF-8 expands to three-byte replacement characters. Bound the
+  // rendered SourceText as well as its raw bytes, retaining a contiguous suffix.
+  const encoder = new TextEncoder();
+  let renderedBytes = encoder.encode(logText(host.bytes)).length;
+  while (renderedBytes > WINDOW_BYTES) {
+    host.bytes = host.bytes.slice(Math.ceil((renderedBytes - WINDOW_BYTES) / 3));
+    host.omitted = true;
+    renderedBytes = encoder.encode(logText(host.bytes)).length;
+  }
+  if (host.omitted || page.reset) {
+    // A bounded tail can begin inside one UTF-8 character. Keep byte cursors opaque.
+    let drop = 0;
+    while (drop < 3 && drop < host.bytes.length && (host.bytes[drop]! & 0xc0) === 0x80) drop++;
+    if (drop) host.bytes = host.bytes.slice(drop);
+  }
+  host.cursor = page.cursor;
+  host.status = 'data';
+  return true;
+}
+export function hostLogResult(
+  state: State,
+  actions: Action[],
+  request: HostLogRead,
+  page: HostLogPage | null,
+) {
+  const host = state.diagnostics.host;
+  if (request.generation !== host.generation || request.attempt !== host.loading) return;
+  host.loading = null;
+  host.failed = !page || !accept(host, page, request.position.kind === 'tail');
+  if (page?.kind === 'data' && page.hasMore && !host.failed && hostLogsOpen(state))
+    readHostLogs(state, actions, { kind: 'after', cursor: page.cursor });
+}
+export function hostLogUpdate(state: State, page: HostLogPage | null) {
+  const host = state.diagnostics.host;
+  if (!hostLogsOpen(state) || !host.watch || host.watch.generation !== host.generation) return;
+  if (!page || !accept(host, page, false)) {
+    stopHostLogs(state);
+    host.failed = true;
+  } else host.failed = false;
+}
+export function hostLogEvent(state: State, actions: Action[], key: string): boolean {
+  if (!key.startsWith('host-log-')) return false;
+  if (!hostLogsOpen(state)) return true;
+  const host = state.diagnostics.host;
+  if (key === 'host-log-refresh') readHostLogs(state, actions);
+  else if (key === 'host-log-stop') stopHostLogs(state);
+  else if (key === 'host-log-follow' && host.loading === null && !host.watch) {
+    host.failed = false;
+    host.watch = {
+      key: 'host-log-watch-' + ++state.serial,
+      input: { position: host.cursor ? { kind: 'after', cursor: host.cursor } : { kind: 'tail' } },
+      generation: host.generation,
+    };
+  }
+  return true;
+}
+function logText(bytes: number[]): string {
+  return new TextDecoder().decode(new Uint8Array(bytes), { stream: true });
+}
+export function hostLogsView<N extends Node>(
+  presentation: Presentation<N>,
+  state: State,
+  theme: Theme,
+  locale: string,
+): N {
+  const ui = presentation.paint(theme),
+    text = copy(locale),
+    host = state.diagnostics.host;
+  const contents = logText(host.bytes);
+  return ui.box(
+    'host-logs',
+    [
+      ui.text('host-log-scope', text.hostLogsLabel, 15, theme.text, 550),
+      ...(host.failed
+        ? [ui.text('host-log-error', text.recoveryRefreshFailed, 13, theme.warning)]
+        : []),
+      ...(host.omitted ? [ui.text('host-log-omitted', text.hostLogsOmitted, 12, theme.muted)] : []),
+      ...(host.reset
+        ? [
+            ui.text(
+              'host-log-reset',
+              host.reset === 'replaced' ? text.hostLogsReplaced : text.hostLogsTruncated,
+              12,
+              theme.warning,
+            ),
+          ]
+        : []),
+      ...(host.status === 'missing'
+        ? [ui.text('host-log-missing', text.hostLogsMissing, 13, theme.muted)]
+        : []),
+      ...(host.status === 'unread' && host.loading !== null
+        ? [ui.text('host-log-loading', text.loading, 13, theme.muted)]
+        : []),
+      presentation.reading(
+        'host-log-reading',
+        {
+          command: null,
+          content: ui.box(
+            'host-log-content',
+            [
+              presentation.slot(
+                'host-log-text',
+                'source',
+                {
+                  source: contents,
+                  appearance: theme.markdown.code,
+                  selection: theme.markdown.selection,
+                  terminal: theme.terminal,
+                  disabled: false,
+                  focusOnPointer: true,
+                },
+                {},
+              ),
+            ],
+            {
+              scroll: host.watch ? 'follow_end' : 'manual',
+              terminal: { grow: 1 },
+              style: { width: fill, height: fill },
+            },
+          ),
+        },
+        {},
+      ),
+      ui.box(
+        'host-log-controls',
+        [
+          ui.button('host-log-refresh', text.check, host.loading !== null),
+          host.watch
+            ? ui.button('host-log-stop', text.stop)
+            : ui.button('host-log-follow', text.hostLogsFollow, host.loading !== null),
+        ],
+        { row: true, gap: 1 },
+      ),
+    ],
+    { gap: 1, terminal: { grow: 1 }, style: { width: fill, height: fill } },
+  );
+}
diff --git a/plugins/agent/agent-screen/index.ts b/plugins/agent/agent-screen/index.ts
index fe9e2ba158..4b54528f00 100644
--- a/plugins/agent/agent-screen/index.ts
+++ b/plugins/agent/agent-screen/index.ts
@@ -42,6 +42,7 @@ export function agentScreen<N extends Node>(
   web = false,
   jev = false,
   graphs = false,
+  hostLogs = false,
 ): Component<State, AgentScreenProps, N> {
   const target = presentation.target;
   const configuredLocale = locale;
@@ -106,6 +107,7 @@ export function agentScreen<N extends Node>(
         dialog: null,
         sessionChange: null,
         diagnostics: newDiagnostics(),
+        hostLogsEnabled: hostLogs,
         inspector: null,
         image: null,
         window: null,
diff --git a/plugins/agent/agent-screen/locale.ts b/plugins/agent/agent-screen/locale.ts
index fb6c4c0c57..412f6e4320 100644
--- a/plugins/agent/agent-screen/locale.ts
+++ b/plugins/agent/agent-screen/locale.ts
@@ -19,6 +19,13 @@
 
 import { createCopy } from '../../shared/locale.js';
 import {
+  msg_diagnosticsConversation,
+  msg_hostLogsLabel,
+  msg_hostLogsMissing,
+  msg_hostLogsOmitted,
+  msg_hostLogsReplaced,
+  msg_hostLogsTruncated,
+  msg_hostLogsFollow,
   msg_recoveryRefreshFailed,
   msg_diagnosticsSessionBusy,
   msg_diagnosticsContinuationChanged,
@@ -375,6 +382,13 @@ import type {
 
 // This feature owns this explicit message catalog; shared messages remain tree-shakeable.
 const messages = {
+  diagnosticsConversation: msg_diagnosticsConversation,
+  hostLogsLabel: msg_hostLogsLabel,
+  hostLogsMissing: msg_hostLogsMissing,
+  hostLogsOmitted: msg_hostLogsOmitted,
+  hostLogsReplaced: msg_hostLogsReplaced,
+  hostLogsTruncated: msg_hostLogsTruncated,
+  hostLogsFollow: msg_hostLogsFollow,
   recoveryRefreshFailed: msg_recoveryRefreshFailed,
   diagnosticsSessionBusy: msg_diagnosticsSessionBusy,
   diagnosticsContinuationChanged: msg_diagnosticsContinuationChanged,
diff --git a/plugins/agent/agent-screen/package.ts b/plugins/agent/agent-screen/package.ts
index b3db205c39..547d7157af 100644
--- a/plugins/agent/agent-screen/package.ts
+++ b/plugins/agent/agent-screen/package.ts
@@ -26,6 +26,7 @@ const entries = uiEntries({
   export: 'AgentScreen',
   imports: {
     agent: one(sdk.contracts.agent),
+    hostLogs: { contract: sdk.contracts.hostLogs, cardinality: { kind: 'optional' } },
     workhub: { contract: sdk.contracts.workhubCoordinator, cardinality: { kind: 'optional' } },
     reading: one(sdk.contracts.readingArea),
     file: one(sdk.contracts.filePicker),
@@ -84,6 +85,7 @@ const entries = uiEntries({
       web: { type: 'boolean' },
       jev: { type: 'boolean' },
       graphs: { type: 'boolean' },
+      hostLogs: { type: 'boolean' },
       place: { type: 'string', maxLength: 4096 },
     },
     ['locale'],
diff --git a/plugins/agent/agent-screen/results.ts b/plugins/agent/agent-screen/results.ts
index 556591890d..5f23ee0a91 100644
--- a/plugins/agent/agent-screen/results.ts
+++ b/plugins/agent/agent-screen/results.ts
@@ -18,6 +18,7 @@
  */
 
 import { compactQuery, compactSettled } from './compact.js';
+import { hostLogResult } from './host-logs.js';
 import { diagnosticResult } from './diagnostics.js';
 import type {
   Action,
@@ -306,6 +307,10 @@ export function receipt(state: State, actions: Action[], request: Mutation, repl
 }
 
 export function queryFailed(state: State, request: Request) {
+  if (request.kind === 'hostLogRead') {
+    hostLogResult(state, [], request, null);
+    return;
+  }
   if (request.kind === 'diagnostics') {
     diagnosticResult(state, request, null);
     return;
@@ -441,6 +446,14 @@ export function queryFailed(state: State, request: Request) {
 
 export function querySucceeded(state: State, actions: Action[], request: Request, value: unknown) {
   switch (request.kind) {
+    case 'hostLogRead':
+      hostLogResult(
+        state,
+        actions,
+        request,
+        value as import('../../../packages/plugin-sdk/src/index.js').HostLogPage,
+      );
+      break;
     case 'diagnostics':
       diagnosticResult(
         state,
diff --git a/plugins/agent/agent-screen/state.ts b/plugins/agent/agent-screen/state.ts
index ea62a31339..d5349f523c 100644
--- a/plugins/agent/agent-screen/state.ts
+++ b/plugins/agent/agent-screen/state.ts
@@ -65,6 +65,7 @@ import { type AttachmentDrafts } from '../../shared/attachments.js';
 
 import { type ComposerReadiness } from '../../shared/drafts.js';
 
+import type { HostLogRead } from './host-logs.js';
 import type { DiagnosticsState, DiagnosticsRequest } from './diagnostics.js';
 
 import type { CompactState, CompactRequest } from './compact.js';
@@ -78,6 +79,7 @@ type BackendPreference = import('../../../packages/plugin-sdk/src/index.js').Age
 type Notice = keyof Copy | null;
 
 export type Request =
+  | HostLogRead
   | DiagnosticsRequest
   | CompactRequest
   | { kind: 'sandboxChange'; epoch: number; sessionId: string }
@@ -303,6 +305,7 @@ export interface State {
   insightsScope: InsightsProps['scope'];
   sidebar: { open: boolean } | { command: TerminalPaneCommand | null };
   diagnostics: DiagnosticsState;
+  hostLogsEnabled: boolean;
   inspector:
     | 'diagnostics'
     | 'details'
diff --git a/plugins/agent/agent-screen/tui.ts b/plugins/agent/agent-screen/tui.ts
index 87add24830..f631858ab3 100644
--- a/plugins/agent/agent-screen/tui.ts
+++ b/plugins/agent/agent-screen/tui.ts
@@ -30,6 +30,7 @@ export function activate(configuration: {
   web?: boolean;
   jev?: boolean;
   graphs?: boolean;
+  hostLogs?: boolean;
 }) {
   component = agentScreen(
     presentations.tui,
@@ -41,6 +42,7 @@ export function activate(configuration: {
     configuration.web === true,
     configuration.jev === true,
     configuration.graphs === true,
+    configuration.hostLogs === true,
   );
   return {
     AgentScreen: { kind: 'component', contract: agentScreenContract, component: ['component'] },
diff --git a/plugins/application/gateway/package.ts b/plugins/application/gateway/package.ts
index 57f5049326..a57d4fc63e 100644
--- a/plugins/application/gateway/package.ts
+++ b/plugins/application/gateway/package.ts
@@ -33,6 +33,7 @@ export default {
         ...native(sdk.nativeImplementations.gateway),
         imports: {
           agent: one(sdk.contracts.agent),
+          hostLogs: { contract: sdk.contracts.hostLogs, cardinality: { kind: 'optional' } },
           coordinator: { contract: sdk.contracts.agent, cardinality: { kind: 'optional' } },
           workhub: {
             contract: sdk.contracts.workhubCoordinator,
diff --git a/platform/host-runtime/src/lib.rs b/plugins/application/host-logs/package.ts
similarity index 56%
copy from platform/host-runtime/src/lib.rs
copy to plugins/application/host-logs/package.ts
index 147389f96c..e119d919f3 100644
--- a/platform/host-runtime/src/lib.rs
+++ b/plugins/application/host-logs/package.ts
@@ -17,16 +17,16 @@
  * under the License.
  */
 
-pub mod application;
-mod directories;
-pub mod http;
-pub mod ipc;
-pub mod recovery;
-pub mod sandbox;
-pub mod shell;
-
-pub use directories::DirectoryRootSpec;
-pub type HostError = Box<dyn std::error::Error + Send + Sync>;
-
-pub mod credentials;
-pub mod storage;
+import * as sdk from '../../../packages/plugin-sdk/src/index.js';
+import { native } from '../../shared/package.js';
+export default {
+  schemaVersion: 1,
+  id: 'maka.application.host-logs',
+  displayName: 'Host logs',
+  description: 'Read and follow captured logs from the connected Host.',
+  localizations: {
+    'zh-CN': { displayName: 'Host 日志', description: '查看和跟随所连接 Host 的已捕获日志。' },
+    'zh-TW': { displayName: 'Host 日誌', description: '查看和跟隨所連線 Host 的已擷取日誌。' },
+  },
+  application: { entries: { host: native(sdk.nativeImplementations.hostLogs) } },
+};
diff --git a/plugins/shared/locale-messages.ts b/plugins/shared/locale-messages.ts
index 9d9d76ec64..0335786129 100644
--- a/plugins/shared/locale-messages.ts
+++ b/plugins/shared/locale-messages.ts
@@ -3089,3 +3089,27 @@ export const msg_toggleSidebar: Message = ['Toggle sidebar', '切换侧栏', '
 export const msg_showSidebar: Message = ['Show sidebar', '显示侧栏', '顯示側欄'];
 export const msg_hideSidebar: Message = ['Hide sidebar', '收起侧栏', '收起側欄'];
 export const msg_connecting: Message = ['Connecting…', '正在连接…', '正在連線…'];
+
+export const msg_diagnosticsConversation: Message = ['Conversation', '对话', '對話'];
+export const msg_hostLogsLabel: Message = ['Host logs', 'Host 日志', 'Host 日誌'];
+export const msg_hostLogsMissing: Message = [
+  'Host log capture file is currently missing.',
+  '当前没有 Host 日志捕获文件。',
+  '目前沒有 Host 日誌擷取檔案。',
+];
+export const msg_hostLogsOmitted: Message = [
+  'Earlier log bytes omitted.',
+  '已省略较早的日志。',
+  '已省略較早的日誌。',
+];
+export const msg_hostLogsReplaced: Message = [
+  'Log file replaced; showing the new file.',
+  '日志文件已替换,正在显示新文件。',
+  '日誌檔案已替換,正在顯示新檔案。',
+];
+export const msg_hostLogsTruncated: Message = [
+  'Log file truncated; showing the current tail.',
+  '日志文件已截断,正在显示当前末尾。',
+  '日誌檔案已截斷,正在顯示目前末尾。',
+];
+export const msg_hostLogsFollow: Message = ['Follow', '跟随', '跟隨'];
diff --git a/products/maka/bundle/package-inputs.json b/products/maka/bundle/package-inputs.json
index 8bed215fab..dccaa3f8cb 100644
--- a/products/maka/bundle/package-inputs.json
+++ b/products/maka/bundle/package-inputs.json
@@ -4,6 +4,7 @@
   "deployment-settings": "plugins/application/deployment-settings",
   "deployment": "plugins/application/deployment",
   "workhub-root": "plugins/workhub/workhub-root",
+  "host-logs": "plugins/application/host-logs",
   "agent-screen": "plugins/agent/agent-screen",
   "apps": "plugins/application/apps",
   "skill-library": "plugins/skills/skill-library",
diff --git a/products/maka/bundle/src/composition.ts b/products/maka/bundle/src/composition.ts
index 0e0b943610..ed5a3e56ff 100644
--- a/products/maka/bundle/src/composition.ts
+++ b/products/maka/bundle/src/composition.ts
@@ -79,6 +79,7 @@ export const packageNames = [
   'deployment',
   'deployment-settings',
   'host-deployment-manager',
+  'host-logs',
   'deployment-manager',
   'deployment-gateway',
   'image-viewer',
@@ -210,6 +211,7 @@ function settingsComposition(
       | 'workspace-jev'
       | 'skills'
       | 'skill-outcomes'
+      | 'host-logs'
       | 'host-deployment-manager',
     exportName: string,
   ): BindingTarget => ({
@@ -234,6 +236,7 @@ function settingsComposition(
     environment: { location: 'host' },
     instances: {
       'host-deployment-manager': node('host-deployment-manager'),
+      ...(gateway === 'gateway' ? { 'host-logs': node('host-logs') } : {}),
       'host-deployment-gateway': node(
         'deployment-gateway',
         {},
@@ -260,6 +263,7 @@ function settingsComposition(
         gateway,
         {},
         {
+          ...(gateway === 'gateway' ? { hostLogs: [local('host-logs', 'HostLogs')] } : {}),
           settings: [local('settings', 'Settings')],
           authentication: [local('settings-authentication', 'Authentication')],
           setup: [local('settings-setup', 'Setup')],
@@ -930,6 +934,7 @@ function agentComposition(configuration: CompositionConfiguration): ApplicationS
       {
         locale: configuration.locale,
         terminals: !!configuration.processes,
+        hostLogs: true,
         skills: !!configuration.skills,
         scheduledTasks: !!configuration.agent.scheduledTasks,
         web: !!configuration.web,
@@ -938,6 +943,7 @@ function agentComposition(configuration: CompositionConfiguration): ApplicationS
       },
       {
         agent: [remote('agent', 'agent')],
+        hostLogs: [remote('host-logs', 'HostLogs')],
         reading: [local('reading-area', 'ReadingArea')],
         file: [local('file', 'FilePicker')],
         receipts: [remote('receipts', 'Receipts')],
@@ -1236,12 +1242,17 @@ function agentComposition(configuration: CompositionConfiguration): ApplicationS
     ...(configuration.presentation === 'tui' ? { diff: node('diff') } : {}),
   });
   ui.launch = local('agent-screen', 'AgentScreen');
+  remoteExports.push({
+    capability: { deployment: 'local', instance: [keys['host-logs']], export: 'HostLogs' },
+    declaration: { contract: contracts.hostLogs, availability: 'required' },
+  });
   remoteExports.unshift({
     capability: { deployment: 'local', instance: [keys.agent], export: 'agent' },
     declaration: { contract: contracts.agent, availability: 'required' },
   });
   Object.assign(connections.hosts.find((host) => host.name === 'local')!.endpoints, {
     agent: expose('gateway', 'agent', Object.keys(contracts.agent.interface.methods), true),
+    hostLogs: expose('gateway', 'hostLogs', ['read', 'watch'], false),
   });
   if (configuration.processes) {
     remoteExports.push({
diff --git a/products/maka/bundle/tests/task-workbench.test.mjs b/products/maka/bundle/tests/task-workbench.test.mjs
index 08c4cd5e47..7e574a6ce6 100644
--- a/products/maka/bundle/tests/task-workbench.test.mjs
+++ b/products/maka/bundle/tests/task-workbench.test.mjs
@@ -23,6 +23,7 @@ import Ajv from 'ajv/dist/2020.js';
 const appearance = await import('../dist/packages/appearance/tui.js');
 const theme = appearance.activate({ scheme: 'dark' }).Appearance.value;
 const module = await import('../dist/packages/agent-screen/tui.js');
+const desktopScreen = await import('../dist/packages/agent-screen/desktop.js');
 module.activate({ locale: 'en' });
 const values = { appearance: [theme] };
 const dataSource = {
@@ -73,9 +74,10 @@ function nodes(node) {
       ]
     : [];
 }
-function client(locale = 'en') {
-  module.activate({ locale });
-  let out = module.component.mount({}, values);
+function client(locale = 'en', options = {}) {
+  const screen = options.target === 'desktop' ? desktopScreen : module;
+  screen.activate({ locale, ...options.configuration });
+  let out = screen.component.mount({}, values);
   const c = {
     get out() {
       return out;
@@ -84,7 +86,7 @@ function client(locale = 'en') {
       return out.frame.state;
     },
     event(input) {
-      out = module.component.event(c.state, input, {}, values);
+      out = screen.component.event(c.state, input, {}, values);
       return c;
     },
     raw(key) {
@@ -116,10 +118,11 @@ function client(locale = 'en') {
       return c.state.visits.find((visit) => visit.id === c.state.selected?.id).key;
     },
   };
-  c.child('recovery-checkpoint', 'changed', {
-    sequence: 0,
-    state: { kind: 'ready', revision: null, value: null },
-  });
+  if (options.target !== 'desktop')
+    c.child('recovery-checkpoint', 'changed', {
+      sequence: 0,
+      state: { kind: 'ready', revision: null, value: null },
+    });
   c.reply(c.action('page'), []);
   c.event({
     kind: 'action',
@@ -127,7 +130,12 @@ function client(locale = 'en') {
     event: {
       status: 'updated',
       value: {
-        sessions: [session('Task A'), session('Task B'), session('Task C'), session('Task D')],
+        sessions: options.sessions ?? [
+          session('Task A'),
+          session('Task B'),
+          session('Task C'),
+          session('Task D'),
+        ],
         before: null,
         after: null,
       },
@@ -1788,3 +1796,234 @@ test('Diagnostics reads common failures and refreshes late background results wi
     ['Another conversation'],
   );
 });
+
+test('Host logs open Diagnostics without a selected conversation through their own capability', () => {
+  const c = client('en', { configuration: { hostLogs: true }, sessions: [] });
+  assert.equal(c.state.selected, null);
+  c.raw('palette-open');
+  const command = c
+    .find('command-palette')
+    .props.commands.find((entry) => entry.id === 'diagnostics-open');
+  assert.ok(
+    command && !command.disabled,
+    'Diagnostics must remain available for Host logs without a selected conversation',
+  );
+  c.child('command-palette', 'invoked', { id: 'diagnostics-open' });
+  const read = c.action('read');
+  assert.ok(read, 'Opening Host diagnostics must query the fixed Host log source');
+  assert.equal(read.binding, 'hostLogs');
+  assert.deepEqual(read.input, { position: { kind: 'tail' } });
+  assert.equal(c.action('diagnostics'), undefined);
+  assert.equal(c.action('observe'), undefined);
+  assert.equal(c.action('history'), undefined);
+  c.reply(read, {
+    kind: 'data',
+    cursor: 'opaque-host-cursor:+/==',
+    bytes: 'SG9zdCBzdGFydGVkCg==',
+    reset: null,
+    truncated: false,
+    hasMore: false,
+  });
+  assert.equal(c.find('host-log-text').props.source, 'Host started\n');
+});
+
+function openDiagnostics(c) {
+  c.raw('palette-open');
+  c.child('command-palette', 'invoked', { id: 'diagnostics-open' });
+  return c;
+}
+function hostPage(cursor, bytes, extra = {}) {
+  return { kind: 'data', cursor, bytes, reset: null, truncated: false, hasMore: false, ...extra };
+}
+function hostUpdate(c, watch, value) {
+  c.event({ kind: 'action', key: watch.key, event: { status: 'updated', value } });
+}
+test('Host logs render literal split UTF-8 in both presentations and stop their independent follow', () => {
+  for (const target of ['tui', 'desktop']) {
+    const c = openDiagnostics(
+      client('en', {
+        target,
+        configuration: { hostLogs: true },
+        sessions: [],
+      }),
+    );
+    // The first byte of 中 is deliberately separated from the remaining bytes.
+    c.reply(c.action('read'), hostPage('opaque:+/=first', '5A=='));
+    assert.equal(c.find('host-log-text').props.source, '');
+    c.raw('host-log-follow');
+    const watch = c.out.subscriptions.find((entry) => entry.binding === 'hostLogs');
+    assert.ok(watch);
+    assert.equal(watch.method, 'watch');
+    assert.deepEqual(watch.input, { position: { kind: 'after', cursor: 'opaque:+/=first' } });
+    hostUpdate(c, watch, hostPage('opaque:+/=second', 'uK0='));
+    assert.equal(c.find('host-log-text').props.source, '中');
+    const raw = '\x1b[31m<command>🙂\n';
+    hostUpdate(c, watch, hostPage('opaque:+/=third', Buffer.from(raw).toString('base64')));
+    assert.equal(c.find('host-log-text').props.source, '中' + raw);
+    assert.deepEqual(
+      c.out.subscriptions.find((entry) => entry.binding === 'hostLogs'),
+      watch,
+    );
+    assert.equal(
+      c.out.actions.some((entry) => entry.binding === 'terminals'),
+      false,
+    );
+    c.raw('host-log-stop');
+    assert.equal(
+      c.out.subscriptions.some((entry) => entry.binding === 'hostLogs'),
+      false,
+    );
+    hostUpdate(c, watch, hostPage('late', 'bGF0ZQ=='));
+    assert.equal(c.find('host-log-text').props.source, '中' + raw);
+    c.raw('host-log-follow');
+    assert.deepEqual(c.out.subscriptions.find((entry) => entry.binding === 'hostLogs').input, {
+      position: { kind: 'after', cursor: 'opaque:+/=third' },
+    });
+    c.raw('diagnostics-close');
+    assert.equal(
+      c.out.subscriptions.some((entry) => entry.binding === 'hostLogs'),
+      false,
+    );
+  }
+});
+test('Host logs retain a bounded tail, clear replaced bytes, and distinguish missing from empty capture', () => {
+  const c = openDiagnostics(client('zh-CN', { configuration: { hostLogs: true }, sessions: [] }));
+  c.reply(c.action('read'), { kind: 'missing' });
+  assert.equal(c.find('host-log-missing').text, '当前没有 Host 日志捕获文件。');
+  c.raw('host-log-follow');
+  const watch = c.out.subscriptions.find((entry) => entry.binding === 'hostLogs');
+  hostUpdate(c, watch, hostPage('empty', ''));
+  assert.equal(c.find('host-log-missing'), undefined);
+  const block = 'a'.repeat(32 * 1024);
+  for (const cursor of ['one', 'two', 'three'])
+    hostUpdate(c, watch, hostPage(cursor, Buffer.from(block).toString('base64')));
+  assert.equal(c.find('host-log-text').props.source, block + block);
+  assert.equal(c.find('host-log-omitted').text, '已省略较早的日志。');
+  hostUpdate(c, watch, hostPage('new-file', 'bmV3Cg==', { reset: 'replaced' }));
+  assert.equal(c.find('host-log-text').props.source, 'new\n');
+  assert.equal(c.find('host-log-omitted'), undefined);
+  assert.equal(c.find('host-log-reset').text, '日志文件已替换,正在显示新文件。');
+  hostUpdate(c, watch, hostPage('shrunk', 'dGFpbA==', { reset: 'truncated', truncated: true }));
+  assert.equal(c.find('host-log-text').props.source, 'tail');
+  assert.equal(c.find('host-log-omitted').text, '已省略较早的日志。');
+  assert.equal(c.find('host-log-reset').text, '日志文件已截断,正在显示当前末尾。');
+});
+test('Host log source survives session changes and stale closed reads cannot replace its new page', () => {
+  const c = openDiagnostics(
+    client('en', { configuration: { hostLogs: true } })
+      .observe(10)
+      .history(page(10, [])),
+  );
+  c.raw('diagnostics-source-host');
+  c.reply(c.action('read'), hostPage('kept-cursor', 'a2VwdA=='));
+  c.raw('host-log-follow');
+  const watch = c.out.subscriptions.find((entry) => entry.binding === 'hostLogs');
+  const transcript = c.out.subscriptions.find(
+    (entry) => entry.binding === 'agent' && entry.method === 'watch',
+  );
+  c.select('Task B');
+  assert.equal(c.find('host-log-text').props.source, 'kept');
+  assert.deepEqual(
+    c.out.subscriptions.find((entry) => entry.binding === 'hostLogs'),
+    watch,
+  );
+  c.raw('diagnostics-source-conversation');
+  assert.equal(
+    c.out.subscriptions.some((entry) => entry.binding === 'hostLogs'),
+    false,
+  );
+  assert.deepEqual(c.action('diagnostics').input, {
+    sessionId: 'Task B',
+    position: { kind: 'latest' },
+  });
+  assert.ok(transcript, 'Host follow coexists with the transcript subscription');
+  c.raw('diagnostics-source-host');
+  const oldRead = c.action('read');
+  c.raw('diagnostics-close');
+  openDiagnostics(c);
+  const newRead = c.action('read');
+  assert.notEqual(oldRead.key, newRead.key);
+  c.reply(newRead, hostPage('fresh', 'ZnJlc2g='));
+  c.reply(oldRead, hostPage('stale', 'c3RhbGU='));
+  assert.equal(c.find('host-log-text').props.source, 'fresh');
+});
+test('Omitting the Host logs feature creates no optional capability demands', () => {
+  const c = client('en', { sessions: [] });
+  c.raw('palette-open');
+  assert.equal(
+    c.find('command-palette').props.commands.find((entry) => entry.id === 'diagnostics-open')
+      .disabled,
+    true,
+  );
+  assert.equal(
+    c.out.actions.some((entry) => entry.binding === 'hostLogs'),
+    false,
+  );
+  assert.equal(
+    c.out.subscriptions.some((entry) => entry.binding === 'hostLogs'),
+    false,
+  );
+  assert.equal(c.find('host-log-text'), undefined);
+  const selected = openDiagnostics(client().observe(10).history(page(10, [])));
+  assert.ok(selected.action('diagnostics'));
+  assert.equal(selected.find('diagnostics-source-host'), undefined);
+  assert.equal(selected.find('host-log-text'), undefined);
+});
+
+test('Host log follow preserves its visible suffix through missing and same-file return while tail refresh starts over', () => {
+  const c = openDiagnostics(client('en', { configuration: { hostLogs: true }, sessions: [] }));
+  c.reply(
+    c.action('read'),
+    hostPage('same-file-cursor', 'a2VwdA==', { truncated: true, reset: 'replaced' }),
+  );
+  c.raw('host-log-follow');
+  let watch = c.out.subscriptions.find((entry) => entry.binding === 'hostLogs');
+  hostUpdate(c, watch, { kind: 'missing' });
+  assert.equal(c.find('host-log-text').props.source, 'kept');
+  assert.equal(c.find('host-log-missing').text, 'Host log capture file is currently missing.');
+  assert.equal(c.find('host-log-omitted').text, 'Earlier log bytes omitted.');
+  assert.equal(c.find('host-log-reset').text, 'Log file replaced; showing the new file.');
+  c.raw('host-log-stop');
+  c.raw('host-log-follow');
+  watch = c.out.subscriptions.find((entry) => entry.binding === 'hostLogs');
+  assert.deepEqual(watch.input, { position: { kind: 'after', cursor: 'same-file-cursor' } });
+  hostUpdate(c, watch, hostPage('same-file-cursor', ''));
+  assert.equal(c.find('host-log-missing'), undefined);
+  assert.equal(c.find('host-log-text').props.source, 'kept');
+  hostUpdate(c, watch, { kind: 'missing' });
+  hostUpdate(c, watch, hostPage('same-file-next-cursor', 'LXN1ZmZpeA=='));
+  assert.equal(c.find('host-log-text').props.source, 'kept-suffix');
+  assert.equal(c.find('host-log-missing'), undefined);
+  assert.equal(c.find('host-log-omitted').text, 'Earlier log bytes omitted.');
+  c.raw('host-log-refresh');
+  c.reply(c.action('read'), { kind: 'missing' });
+  assert.equal(c.find('host-log-text').props.source, '');
+  assert.equal(c.find('host-log-omitted'), undefined);
+  assert.equal(c.find('host-log-reset'), undefined);
+  c.raw('host-log-follow');
+  assert.deepEqual(c.out.subscriptions.find((entry) => entry.binding === 'hostLogs').input, {
+    position: { kind: 'tail' },
+  });
+});
+
+test('Host log invalid UTF-8 replacements stay within the rendered text budget and retain the newest suffix', () => {
+  const c = openDiagnostics(client('en', { configuration: { hostLogs: true }, sessions: [] }));
+  c.reply(
+    c.action('read'),
+    hostPage('binary-tail', Buffer.alloc(32 * 1024, 0xff).toString('base64')),
+  );
+  let displayed = c.find('host-log-text').props.source;
+  assert.ok(
+    Buffer.byteLength(displayed) <= 65536,
+    'Decoded log text must fit the SourceText UTF-8 budget',
+  );
+  assert.ok(displayed.includes('\ufffd'));
+  assert.equal(c.find('host-log-omitted').text, 'Earlier log bytes omitted.');
+  c.raw('host-log-follow');
+  const watch = c.out.subscriptions.find((entry) => entry.binding === 'hostLogs');
+  hostUpdate(c, watch, hostPage('text-suffix', 'dGFpbAo='));
+  displayed = c.find('host-log-text').props.source;
+  assert.ok(Buffer.byteLength(displayed) <= 65536);
+  assert.ok(displayed.endsWith('tail\n'));
+  assert.ok(displayed.includes('\ufffd'));
+});
diff --git a/products/maka/cli/README.md b/products/maka/cli/README.md
index e00d53436d..65cc71a3a5 100644
--- a/products/maka/cli/README.md
+++ b/products/maka/cli/README.md
@@ -33,6 +33,8 @@ Startup discovers the local Host and connects to it, or starts one automatically
 
 Headless commands (`host`, `application serve`, `code` and `acp`) install process logging before runtime initialization. Warnings and errors, including SQLx slow-query warnings, go to stderr without ANSI styling; stdout remains available for command results and protocol frames. Ordinary DEBUG query logs are disabled. Interactive products keep control of their terminal output.
 
+Dedicated Host processes capture stderr in their Root's private control-cache file `host.stderr.log`: on-demand capture starts before spawning, while foreground serving starts after Root selection. Read it with `maka host logs --root-id ID`, or add `--follow` for live NDJSON and `--after CURSOR` to resume. Pages preserve raw bytes as Base64 and report missing capture, replacement or truncation; clearing the control cache removes these logs. This works without an installed service. The de [...]
+
 ## Product updates, recovery and URL installation
 
 To update a standalone Host's selected bundled plugins, stop that Host and use its State Root:
diff --git a/products/maka/cli/README.zh-CN.md b/products/maka/cli/README.zh-CN.md
index 831a879ed7..d78c3555d4 100644
--- a/products/maka/cli/README.zh-CN.md
+++ b/products/maka/cli/README.zh-CN.md
@@ -33,6 +33,8 @@
 
 无界面命令(`host`、`application serve`、`code` 和 `acp`)在运行时初始化前安装进程日志订阅器。警告和错误(包括 SQLx 慢查询警告)写入 stderr,不带 ANSI 样式;stdout 保留给命令结果和协议帧。默认不输出普通 DEBUG 查询日志。交互式产品仍自行管理终端输出。
 
+独立 Host 将 stderr 捕获到所属 Root 的私有控制缓存文件 `host.stderr.log`:按需启动在创建进程前接好捕获文件,前台服务在选定 Root 后开始捕获。使用 `maka host logs --root-id ID` 读取,添加 `--follow` 持续接收 NDJSON,添加 `--after CURSOR` 从游标续读。每页以 Base64 保留原始字节,并报告尚无捕获文件、文件替换或截断;清理控制缓存也会删除这些日志。读取不要求安装系统服务。默认产品的“诊断 → Host 日志”无需会话即可读取和跟随日志。
+
 ## 产品更新、恢复与 URL 安装
 
 更新独立 Host 已选的内置插件时,先停止该 Host,再指定它的 State Root:
diff --git a/products/maka/cli/notices.json b/products/maka/cli/notices.json
index de925091c6..2fb7e184c0 100644
--- a/products/maka/cli/notices.json
+++ b/products/maka/cli/notices.json
@@ -5,10 +5,10 @@
     "package-lock.json": "a5af15aca21fabe0e1bdd48784167df4dc81cee17777d54861dcbd641168a4eb",
     "package.json": "38baf3beeb5876849ca16e31868d59bfaee769f260c7555b4a8a1847b92e28a1",
     "packages/plugin-sdk/src/configurations.ts": "20e587b47e84a31fc794176f85e7ac71c1b8b7f88a46947f9403011c307fff1e",
-    "packages/plugin-sdk/src/contracts.ts": "955e194f5156a87bb56459325067ea0246b76d29096e80a5b6793cc823c2f699",
-    "packages/plugin-sdk/src/generated/runtime.ts": "ff0e1c027c17ed026457993a95c168d2d9b0d334b609f97475ab7a2d91facafc",
+    "packages/plugin-sdk/src/contracts.ts": "d1207817a5c183cc99defa614ef73b5534e4eb8a79152e8c1c9abfd406211579",
+    "packages/plugin-sdk/src/generated/runtime.ts": "66e0d36196e303b32d8464cdecec86c419da124d9f29ce473e0070855a1b8fe2",
     "packages/plugin-sdk/src/index.ts": "27b68be5a29d1857f7fe361c8c678e77ba21413c79ec3870c1ce30d0faf9323f",
-    "packages/plugin-sdk/src/native.ts": "2cf374759664132f1eba27b3c49c375174ad7e748e6d28a68ed07dde7ab05bf7",
+    "packages/plugin-sdk/src/native.ts": "d29328923ca55874fe1a0fc37985097196d805a09b56043c1a3379fd46bd5a20",
     "packages/plugin-sdk/src/ui.ts": "d6c64e9406c41ad6bbda73435b559aad24c31adb3c5bbb4ab6f49fff8a8330ca",
     "patches/zod+4.6.5.patch": "5a6cbff1b31ca2a5d2b7c006abb7b1002241dbf4ae42c315c27940201f52b081",
     "platform/background-health/Cargo.toml": "41e89dc7e43adf066306b03b3083cbd4163a5550f39d5be6669463d3c16c53a5",
@@ -43,33 +43,34 @@
     "plugins/agent/acp-executor/engine/tests/support/tui-auth.mjs": "b66436ace65b5874b6cd3ab66ece94984552c4a0772a0ae2d9b05db6b9f47d0f",
     "plugins/agent/acp-executor/native/Cargo.toml": "069e5db4798166d921dc48424101f9460516c8683f0356272e2398b847b460b4",
     "plugins/agent/acp-executor/package.ts": "d85747d6f02da7a316859d04d87706bf425699b90bf4053198a64b50eabd391f",
-    "plugins/agent/agent-screen/actions.ts": "b39d33100e4159484d206aa7f1f1f29dd1519bf3bc069f37b49d8977ea905034",
+    "plugins/agent/agent-screen/actions.ts": "1c885c291301131d8d39808327324eaa0d8c08c06146b2e15c9b37993fd3da00",
     "plugins/agent/agent-screen/checklist-view.ts": "e2976d6408df0407af4c26c9221a143761197f800f7a4f620639dad80091192f",
     "plugins/agent/agent-screen/compact.ts": "358817341afba4c6ca8cace67e1e547994888b2a91793b3620af020e5a255436",
     "plugins/agent/agent-screen/component-events.ts": "bd1a04c613532bf2fc7c16997fd38565323fac77b2b38d710e6de815d34d0166",
     "plugins/agent/agent-screen/composer-status.ts": "ff8380142e1b247d508b5be10d2089b353019de8a705169703fd53f3008b2db9",
     "plugins/agent/agent-screen/conversation-view.ts": "aeac5aba3c9fa6687d3c683b89af31884d9bab1e0ada1750b91812f57e3a750e",
-    "plugins/agent/agent-screen/desktop.ts": "9cd44cb6a5225feead1aa87fc4e2d7935b994022fa38175342330119e72c4660",
-    "plugins/agent/agent-screen/diagnostics.ts": "282a5700b2f9845c49046d51dbcdc7cf65449fcfc728e482442105cb6a9de768",
-    "plugins/agent/agent-screen/events.ts": "68f83b17ea8f772453e264731497a4fd1ed0e0fde35c520ce6523ffe2afa973a",
-    "plugins/agent/agent-screen/frame.ts": "594e156e06b07387690b6671c5431c5c4609cf13e7690e3dc5851a65b22da9ee",
+    "plugins/agent/agent-screen/desktop.ts": "b856ee9f0c43f99df47c68070ba9845164e07b1282a30107a87e4fa6ef9da260",
+    "plugins/agent/agent-screen/diagnostics.ts": "49af51a1ba6791cb0d8cb81cf5a2d26c870e443f1435fc9344960a4421a029ae",
+    "plugins/agent/agent-screen/events.ts": "826dc21ee00ce952ce8910a530b8ab097140d72f9b4cede5272214d6c8e892bb",
+    "plugins/agent/agent-screen/frame.ts": "659fb6233b5edbc0be05edd94d9e32b04c4fd2668adbdbe2924f7ba25f1c2b97",
     "plugins/agent/agent-screen/goal.ts": "b1ce1874dd617e7ace1fbea2f0dd2069277dbd0b12173f538d585db1b3bdc9f7",
     "plugins/agent/agent-screen/graph.ts": "a60a38979fba5a0dd1dfa944f28ce8d1768c7cd84b3a95bbaddc6731a39922c1",
-    "plugins/agent/agent-screen/header-view.ts": "ddb4e64e5c1cb10fa0a9b0d9f64b3223c74e2a4b3344193aafc8924088941c61",
-    "plugins/agent/agent-screen/index.ts": "593fc28c0494ff2c624548c7fc2f2074da718c9b994a129d73438b68ef50cc1f",
-    "plugins/agent/agent-screen/locale.ts": "bcd8dcf72200903cef623271f0ff6d928c3fdb97efccecf03fc518dcdefa92f2",
+    "plugins/agent/agent-screen/header-view.ts": "ba3c727a586e45561f59b756480fa2babaa80a9be718d1b9661353d6fb80c8a2",
+    "plugins/agent/agent-screen/host-logs.ts": "9cb76e7659a5134359420d24479fd2ce9c055a4260e718ff55972982cd67dce8",
+    "plugins/agent/agent-screen/index.ts": "4d3162d71038a22e58646d36669873bdf1159491174f65317f1ef55f37c313be",
+    "plugins/agent/agent-screen/locale.ts": "068a5c685a84f27adcfa8b22f8e85ceb94cd39b32775547af3f184532e513b0d",
     "plugins/agent/agent-screen/messages.ts": "98b41c7631e043d73bb389a38da6aeb39b80f9f14fa0009abd07d950adc072d2",
     "plugins/agent/agent-screen/navigation-view.ts": "4f4df48a7714eabe7159db161a03abddcc742cd5d71fe4cd488a7a301a3979a5",
     "plugins/agent/agent-screen/outputs-view.ts": "c77e4b71ccd122bac25c0b2108f3261b3e3b84f3f024487cc389d7042f9932aa",
-    "plugins/agent/agent-screen/package.ts": "2a9d1635153ff46fe52313bdb70934c0818ac99350665c509e4d47ba2401bd45",
+    "plugins/agent/agent-screen/package.ts": "43a1adec950132590ec3da890e3ce0ebf24c2f82cbf3f9f8ceb12f1c9415ee47",
     "plugins/agent/agent-screen/pages-view.ts": "661e061479a340a98a53dd20ced7e814930f61d0433a133b05bce2a00181010c",
     "plugins/agent/agent-screen/plan.ts": "88f600ede57041a4f83acd485c532324b8ab52842471985c5e8e2b8a76d50a00",
     "plugins/agent/agent-screen/raw-events.ts": "b7bb99f638f7655168cdff083681b22862884c4e1f865f758e36959969a4c328",
     "plugins/agent/agent-screen/recap-view.ts": "eba743e827b0fe0832430955f8d3d1f5a43dd37526a94ecf1d9daab666e58185",
-    "plugins/agent/agent-screen/results.ts": "b605d48135986f93549753dcd042de3c27004872ca4476de3d7325bde621b921",
+    "plugins/agent/agent-screen/results.ts": "d8606a2cfe24b742db28a46f65ac93b50aa5f315226348c3ee61a685e539d4fd",
     "plugins/agent/agent-screen/sandbox.ts": "344159383d98bd542f9614f2466f78ceee4b892adce3c9c13c355dd4783c17df",
-    "plugins/agent/agent-screen/state.ts": "7c98a87c60dd820f09b4895f15e0a3b3f6ba7718ec0912d45c37f4a51e0a4797",
-    "plugins/agent/agent-screen/tui.ts": "3b1de4b1aa847692e94b66262a1a6e8c21d1111d61f5d86ad28d13adb712d856",
+    "plugins/agent/agent-screen/state.ts": "318aab5b7bb3519eab43b92438d25eb73bca7e2aafec5f221177e8c6eb0ef188",
+    "plugins/agent/agent-screen/tui.ts": "9ae58c3f40030c6f5358f71d5bd8521741e0e98a3b39893a3242e7f7d386fcb7",
     "plugins/agent/agent/package.ts": "d4929e8b6e276705dbb3049b840ea64f53dd7d52176e2036d2f769df5e7bc82f",
     "plugins/agent/api/Cargo.toml": "7f33b8ad23980598820684a18fbde28a078a8624e951147564da3d121736fecc",
     "plugins/agent/assistant-preferences/desktop.ts": "7f5cc8f2087bf85f6b32b94b9110f87cf2acb6d46da9d581efc19c886c2ecd80",
@@ -240,9 +241,10 @@
     "plugins/application/deployment/state.ts": "85d3e24816f7260ea0ba576f6f0bbf342dcdf37b6ce8ea74264bde3d24689223",
     "plugins/application/deployment/tui.ts": "c0b4613094b455d609f1136c29d997720fb9910a0e65c6cada72769faebd984b",
     "plugins/application/deployment/view.ts": "9174e7942ec465b108e2c4c6006117ce0e54c023174adde04b0785764f0e7be6",
-    "plugins/application/gateway/package.ts": "44a7c14c19f2d98f664a05b18c3dbc9f6616a19899efccea55930419860f79e1",
+    "plugins/application/gateway/package.ts": "ccbf5f3222a3703832288270046b063a03bac43f81f27917312f3a34303f9785",
     "plugins/application/history/package.ts": "4174cd62ed483789ad47d6757b6ceeba95ab7c9fbba234264eda7f48b1cef477",
     "plugins/application/host-deployment-manager/package.ts": "c5c390273e8adc6b1192b047d9ac7e5776b8a4cd422a99ead5765e8eed35cfdb",
+    "plugins/application/host-logs/package.ts": "a5e7360a36ab3f36daf9e1b57161bab81c3cf2a6254340c52e17fbd59109a615",
     "plugins/application/receipts/package.ts": "cf15f59e2f8e924178d1ac11b22ff2c774c499aa8ca950154e51a98e7800cf95",
     "plugins/application/recovery/desktop.ts": "c354b12b43ad745e4e0ab0cbefd5936814ac05acc57b7d22648045113bb0b76e",
     "plugins/application/recovery/index.ts": "1da180c02283a986cbe9cea8ce671be9d7a7b0ee33d635527df6b1d1558454e3",
@@ -337,7 +339,7 @@
     "plugins/shared/locale-catalogs/page-shell.ts": "9344fe797d2dfe699ba5b817e64cdc731877b67ed544f2595b28ae952672d4b5",
     "plugins/shared/locale-catalogs/settings.ts": "94d9bfaf031b6d9887cda7c0eb09fc359da9e6ee3f652844a6d0a61bbed77868",
     "plugins/shared/locale-catalogs/skill-import.ts": "80f9181724196d882a428dee893c04fe33fedefaaba5ebbfa4f7372148982294",
-    "plugins/shared/locale-messages.ts": "dc239da1636b091e57d4f4ae299474047605a358b4d9f1aa3d36c64ea0341ec7",
+    "plugins/shared/locale-messages.ts": "54ae3d8a23ba41876143ad0dc1ceafee8c2f58d8529177d7248c09c31c1481fe",
     "plugins/shared/locale.ts": "d3d772870643169345756a37af4718e52cdbeb59f5a2bbb9418023607b7ead1f",
     "plugins/shared/message-copy.ts": "2f7bfab4b186e1ff5784d4b6e75218c02ac92e3b7f301d0c3047b6ada6fb5ad7",
     "plugins/shared/message-view.ts": "a571ca1202cdc7e9cbb7365aa41b49f015bb3777d2405fd54bf671287cbfa9f4",
@@ -428,10 +430,10 @@
     "plugins/workspace/workspace-web/native/Cargo.toml": "e40d175d4ed5300b1c43ecc3af89e9b90c806560b3bda65898a742b39559ca74",
     "plugins/workspace/workspace-web/package.ts": "1708c7cef2dc40f54c732ed01988af9f3b5b435e3f4909c63a4b992ed82ec436",
     "products/maka/acp/Cargo.toml": "7ca393447aafad9880cd40741c8abe80e903ad14d52cf20706ebfe5bbc88aa85",
-    "products/maka/bundle/package-inputs.json": "b84a2d9fea512fc7ade745a04ec545b360dd752064aa3ea819832d915b8cf77d",
+    "products/maka/bundle/package-inputs.json": "38dc88014c68956c94be539febcb9547b6962b11851c1f184bf2e14d6fe0a92c",
     "products/maka/bundle/package.json": "dd17e0a82ab68c03cdfd8adb3e243d4c5e881530db66ff311cb642b3bdc1f7c0",
     "products/maka/bundle/scripts/build.mjs": "db11d8cd6f29ef7ead087261848568da1ad3889a870a89f23fa969449d769d47",
-    "products/maka/bundle/src/composition.ts": "e6f1ef1d6f2a02616ba3416420704119e13310ec2585785cdc5dac4fbb657b18",
+    "products/maka/bundle/src/composition.ts": "c3ef908bb77fd6a65aeb38635709dec650c6b0a84840910df0b90433d342cfeb",
     "products/maka/bundle/src/installation.ts": "614e9ed4ee41c26f9a398da21b1c5a4b41d91a22080a472d64207c5826e491fc",
     "products/maka/cli/Cargo.toml": "2a41d9dd434d08bb62cfe784e32b9e9facd41c58bbe9eb1b809b3fde3a2ed446",
     "products/maka/cli/build.rs": "98452274bb0f20fa55fda5a20313f81740e759652aa4227fba0b0de5e3f4cc29",
diff --git a/products/maka/cli/src/application/serve.rs b/products/maka/cli/src/application/serve.rs
index c80175e4fb..5e31046710 100644
--- a/products/maka/cli/src/application/serve.rs
+++ b/products/maka/cli/src/application/serve.rs
@@ -51,6 +51,16 @@ pub(super) fn native() -> Result<NativeRegistry, HostError> {
     Ok(native)
 }
 pub(super) async fn run(args: Serve) -> Result<(), HostError> {
+    let owner = tokio::task::spawn_blocking(move || {
+        maka_event_log::root::RootOwner::open(
+            &args.root,
+            &maka_event_log::root::RootNamespaces::for_current_account()?,
+        )
+    })
+    .await??;
+    let logs = maka_host_runtime::logs::Source::from_owner(&owner)?;
+    eprintln!("Host diagnostics: {}", logs.path().display());
+    crate::logging::capture(logs.open_capture()?)?;
     let endpoints: BTreeMap<String, HostEndpoint> = super::read_json(&args.endpoints)?;
     let grants: Vec<NativeGrant> = args
         .native
@@ -58,7 +68,7 @@ pub(super) async fn run(args: Serve) -> Result<(), HostError> {
         .map(super::read_json)
         .transpose()?
         .unwrap_or_default();
-    let store = Arc::new(super::open_store(args.root).await?);
+    let store = Arc::new(maka_transport::open_store(Arc::new(owner)).await?);
     let host = Host::open_with_options(
         store,
         args.deployment,
diff --git a/products/maka/cli/src/args.rs b/products/maka/cli/src/args.rs
index 5ed7a95440..03ccdb2e0f 100644
--- a/products/maka/cli/src/args.rs
+++ b/products/maka/cli/src/args.rs
@@ -95,7 +95,7 @@ enum HostCommand {
     Uninstall(crate::deployment::Control),
     /// Observe a Host or managed deployment without starting or changing it.
     Status(crate::deployment::Status),
-    /// Read a bounded tail of supervised Host diagnostics without starting it.
+    /// Read or follow captured Host diagnostics without starting it.
     Logs(crate::deployment::Logs),
     /// Retire the exact current Host when idle, or explicitly interrupt its work.
     Retire {
@@ -165,7 +165,8 @@ impl Cli {
         }
         let finite = matches!(&self.command, Some(Command::Host(command)) if !matches!(command,
             HostCommand::Candidate(_) | HostCommand::Serve { .. } | HostCommand::ServiceRun(_)
-            | HostCommand::Connect(_) | HostCommand::Ensure(_)));
+            | HostCommand::Connect(_) | HostCommand::Ensure(_))
+            && !matches!(command, HostCommand::Logs(logs) if logs.follow));
         if finite {
             let default = if matches!(
                 &self.command,
diff --git a/products/maka/cli/src/deployment/activation.rs b/products/maka/cli/src/deployment/activation.rs
index 4c9ea90dfc..0df99ea276 100644
--- a/products/maka/cli/src/deployment/activation.rs
+++ b/products/maka/cli/src/deployment/activation.rs
@@ -226,6 +226,8 @@ pub(super) async fn connect_or_launch(
                             super::service::start(deployment.clone(), lease.clone(), owner).await?;
                             service_started = true;
                         } else {
+                            let capture = maka_host_runtime::logs::Source::from_owner(&owner)?
+                                .open_capture()?;
                             drop(owner);
                             child = Some(
                                 detached::spawn(
@@ -246,6 +248,7 @@ pub(super) async fn connect_or_launch(
                                         "--initial-connection-timeout-ms",
                                         "30000",
                                     ],
+                                    Some(capture),
                                 )
                                 .await?,
                             );
@@ -327,9 +330,10 @@ mod tests {
     async fn failed_candidate_drains_or_reports_unconfirmed_without_killing_its_authority() {
         // No descendants: each child is either an EOF-driven reader or exec'd sleep.
         for (command, graceful) in [("read line; exit 0", true), ("exec sleep 60", false)] {
-            let mut child = detached::spawn(std::path::Path::new("/bin/sh"), &["-c", command])
-                .await
-                .unwrap();
+            let mut child =
+                detached::spawn(std::path::Path::new("/bin/sh"), &["-c", command], None)
+                    .await
+                    .unwrap();
             let result = tokio::time::timeout(
                 Duration::from_secs(3),
                 stop_failed_candidate(&mut child, Duration::from_millis(250)),
diff --git a/products/maka/cli/src/deployment/diagnostics.rs b/products/maka/cli/src/deployment/diagnostics.rs
index 9b10233876..d36fd75930 100644
--- a/products/maka/cli/src/deployment/diagnostics.rs
+++ b/products/maka/cli/src/deployment/diagnostics.rs
@@ -19,7 +19,9 @@
 
 //! Failure-only, read-only queries isolated from the Host's execution lifetime.
 
-use super::{Deployment, HostError, logs, service};
+use super::{Deployment, HostError, service};
+use base64::Engine;
+use maka_host_runtime::logs::HostLogPage;
 use maka_process::{Command, pipe};
 use serde::Deserialize;
 use std::{io, time::Duration};
@@ -79,7 +81,7 @@ async fn query(
         &deployment.root_id,
     ]);
     // Only status/logs: terminating this worker cannot cancel accepted Host work.
-    // Its process group/Job also owns launchctl, systemctl or journalctl children.
+    // Its process group/Job also owns launchctl or systemctl children.
     let bytes = read_only_worker(command, deadline).await?;
     match query {
         Query::Status => {
@@ -93,8 +95,11 @@ async fn query(
             Ok(serde_json::to_string(&supervisor)?)
         }
         Query::Logs => match serde_json::from_slice(&bytes)? {
-            logs::Output::NotCaptured => Ok("No service log captured".into()),
-            logs::Output::Tail { text, .. } => Ok(text),
+            HostLogPage::Missing => Ok("No Host log captured".into()),
+            HostLogPage::Data { bytes, .. } => {
+                let raw = base64::engine::general_purpose::STANDARD.decode(bytes)?;
+                Ok(String::from_utf8_lossy(&raw).into_owned())
+            }
         },
     }
 }
diff --git a/products/maka/cli/src/deployment/entry.rs b/products/maka/cli/src/deployment/entry.rs
index 5f4085bf44..d8d5b62911 100644
--- a/products/maka/cli/src/deployment/entry.rs
+++ b/products/maka/cli/src/deployment/entry.rs
@@ -29,10 +29,14 @@ pub(crate) struct ServiceRun {
 
 impl ServiceRun {
     pub async fn run(self) -> Result<(), HostError> {
+        let source = maka_host_runtime::logs::Source::for_root(
+            &self.root_id.0,
+            &maka_event_log::root::RootNamespaces::for_current_account()?,
+        )?;
+        crate::logging::capture(source.open_capture()?)?;
         let directory = directory(&self.root_id.0)?;
         #[cfg(windows)]
         {
-            crate::windows::service_stderr(&directory)?;
             crate::windows::own_process_tree()?;
         }
         let store::Installation::Installed(deployment) = store::read(&directory).await? else {
diff --git a/products/maka/cli/src/deployment/local.rs b/products/maka/cli/src/deployment/local.rs
index 8a170f34cd..5c3a4fcd6d 100644
--- a/products/maka/cli/src/deployment/local.rs
+++ b/products/maka/cli/src/deployment/local.rs
@@ -143,6 +143,8 @@ async fn connect_inner(
                             return Err("State Root changed before launch".into());
                         }
                         let canonical = owner.canonical_path().to_owned();
+                        let capture =
+                            maka_host_runtime::logs::Source::from_owner(&owner)?.open_capture()?;
                         drop(owner);
                         child = Some(
                             detached::spawn(
@@ -160,6 +162,7 @@ async fn connect_inner(
                                     "--initial-connection-timeout-ms",
                                     "30000",
                                 ],
+                                Some(capture),
                             )
                             .await?,
                         );
diff --git a/products/maka/cli/src/deployment/logs.rs b/products/maka/cli/src/deployment/logs.rs
index 82a4bf81a6..859db0877a 100644
--- a/products/maka/cli/src/deployment/logs.rs
+++ b/products/maka/cli/src/deployment/logs.rs
@@ -17,202 +17,70 @@
  * under the License.
  */
 
-use super::{Deployment, Mode, RootId, directory, store};
+use super::RootId;
 use clap::Args;
-use maka_host_runtime::HostError;
-use serde::{Deserialize, Serialize};
-
-const LIMIT: usize = 48 * 1024;
+use maka_event_log::root::RootNamespaces;
+use maka_host_runtime::{
+    HostError,
+    logs::{HostLogCursor, HostLogPosition, HostLogRequest, Source},
+};
+use std::{io::Write, sync::Arc};
+use tokio::{io::AsyncWriteExt, sync::Mutex};
+use tokio_util::sync::CancellationToken;
 
 #[derive(Args)]
 pub(crate) struct Logs {
     #[arg(long)]
     root_id: RootId,
-}
-
-#[derive(Deserialize, Serialize)]
-#[serde(
-    tag = "kind",
-    rename_all = "snake_case",
-    rename_all_fields = "camelCase"
-)]
-pub(super) enum Output {
-    NotCaptured,
-    Tail {
-        source: Source,
-        text: String,
-        byte_truncated: bool,
-    },
-}
-
-#[derive(Deserialize, Serialize)]
-#[serde(
-    tag = "kind",
-    rename_all = "snake_case",
-    rename_all_fields = "camelCase"
-)]
-pub(super) enum Source {
-    #[cfg(target_os = "linux")]
-    Journal { entry_limit: usize },
-    #[cfg(any(target_os = "macos", windows))]
-    Stderr,
+    #[arg(long)]
+    pub(crate) follow: bool,
+    #[arg(long)]
+    after: Option<HostLogCursor>,
 }
 
 impl Logs {
     pub async fn run(self) -> Result<(), HostError> {
-        let directory = directory(&self.root_id.0)?;
-        let store::Installation::Installed(deployment) = store::read(&directory).await? else {
-            return Err("State Root has no installed deployment".into());
-        };
-        if deployment.root_id != self.root_id.0 {
-            return Err("deployment root identity differs".into());
-        }
-        deployment.validate_record(&directory)?;
-        let output = if deployment.mode == Mode::OnDemand {
-            Output::NotCaptured
-        } else {
-            read(&deployment).await?
-        };
-        println!("{}", serde_json::to_string(&output)?);
-        Ok(())
-    }
-}
-
-#[cfg(any(target_os = "macos", windows))]
-async fn read(deployment: &Deployment) -> Result<Output, HostError> {
-    let directory = directory(&deployment.root_id)?;
-    tokio::task::spawn_blocking(move || {
-        use std::io::{Read, Seek, SeekFrom};
-        let path = directory.join("host.stderr.log");
-        #[cfg(target_os = "macos")]
-        let opened = {
-            use std::os::unix::fs::OpenOptionsExt;
-            std::fs::OpenOptions::new()
-                .read(true)
-                .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK)
-                .open(&path)
+        let source = Source::for_root(&self.root_id.0, &RootNamespaces::for_current_account()?)?;
+        let request = HostLogRequest {
+            position: match self.after {
+                Some(cursor) => HostLogPosition::After { cursor },
+                None => HostLogPosition::Tail,
+            },
         };
-        #[cfg(windows)]
-        let opened = maka_event_log::root::windows::open_nofollow(&path, false);
-        let mut file = match opened {
-            Ok(file) => file,
-            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
-                return Ok(Output::NotCaptured);
+        if self.follow {
+            let cancel = CancellationToken::new();
+            let _signals = crate::signals::watch(cancel.clone())?;
+            let output =
+                maka_transport::stdio::Output::open("host-logs-stdout", std::io::stdout())?;
+            let writer = Arc::new(Mutex::new(output.writer));
+            let result = source
+                .watch(request, cancel.clone(), |page| {
+                    let writer = writer.clone();
+                    async move {
+                        let mut bytes = serde_json::to_vec(&page)?;
+                        bytes.push(b'\n');
+                        writer.lock().await.write_all(&bytes).await
+                    }
+                })
+                .await;
+            // Source::watch selects cancellation while an emit is backpressured.
+            // Its dropped future releases this last bounded writer; the stdio
+            // thread owns no Root lease and need not drain a full pipe on cancel.
+            drop(writer);
+            result?;
+            if !cancel.is_cancelled() {
+                tokio::select! {
+                    _ = cancel.cancelled() => {},
+                    done = output.done => done.map_err(std::io::Error::other)??,
+                }
             }
-            Err(error) => return Err(error.into()),
-        };
-        let metadata = file.metadata()?;
-        if !metadata.is_file() {
-            return Err("service log is not a regular file".into());
-        }
-        #[cfg(target_os = "macos")]
-        {
-            use std::os::unix::fs::MetadataExt;
-            if metadata.uid() != unsafe { libc::geteuid() }
-                || metadata.mode() & 0o077 != 0
-                || metadata.nlink() != 1
-            {
-                return Err("service log is not private and singly linked".into());
-            }
-        }
-        #[cfg(windows)]
-        {
-            use maka_event_log::root::windows::{file_identity, validate_private};
-            validate_private(&file)?;
-            if file_identity(&file)?.links != 1 {
-                return Err("service log is not singly linked".into());
-            }
-        }
-        // Freeze the end offset; an active writer must not make this a follow.
-        let start = metadata.len().saturating_sub(LIMIT as u64);
-        file.seek(SeekFrom::Start(start))?;
-        let mut bytes = Vec::with_capacity(LIMIT);
-        file.take(metadata.len() - start).read_to_end(&mut bytes)?;
-        Ok(Output::Tail {
-            source: Source::Stderr,
-            text: String::from_utf8_lossy(&bytes).into_owned(),
-            byte_truncated: start != 0,
-        })
-    })
-    .await?
-}
-
-#[cfg(target_os = "linux")]
-async fn read(deployment: &Deployment) -> Result<Output, HostError> {
-    use std::{process::Stdio, time::Duration};
-    const ENTRIES: usize = 200;
-    let mut child = tokio::process::Command::new("journalctl")
-        .args([
-            "--user",
-            "--no-pager",
-            "--quiet",
-            "--all",
-            "--output=short-iso-precise",
-        ])
-        .arg(format!(
-            "--user-unit=org.apache.maka.host.{}.service",
-            deployment.root_id
-        ))
-        .arg(format!("--lines={ENTRIES}"))
-        .stdin(Stdio::null())
-        .stdout(Stdio::piped())
-        .stderr(Stdio::piped())
-        .kill_on_drop(true)
-        .spawn()?;
-    let stdout = child.stdout.take().ok_or("journal stdout unavailable")?;
-    let stderr = child.stderr.take().ok_or("journal stderr unavailable")?;
-    let result = tokio::time::timeout(Duration::from_secs(10), async {
-        tokio::try_join!(tail(stdout, LIMIT), tail(stderr, 2048), child.wait())
-    })
-    .await;
-    let (stdout, stderr, status) = match result {
-        Ok(Ok(output)) => output,
-        result => {
-            // Reap the query even on timeout or pipe failure; no pager is spawned.
-            let _ = child.kill().await;
-            return Err(match result {
-                Err(error) => error.into(),
-                Ok(Err(error)) => error.into(),
-                Ok(Ok(_)) => unreachable!(),
-            });
-        }
-    };
-    if !status.success() {
-        return Err(format!(
-            "journal query failed ({status}): {}",
-            String::from_utf8_lossy(&stderr.0)
-        )
-        .into());
-    }
-    Ok(Output::Tail {
-        source: Source::Journal {
-            entry_limit: ENTRIES,
-        },
-        text: String::from_utf8_lossy(&stdout.0).into_owned(),
-        byte_truncated: stdout.1,
-    })
-}
-
-#[cfg(target_os = "linux")]
-async fn tail(
-    mut input: impl tokio::io::AsyncRead + Unpin,
-    limit: usize,
-) -> std::io::Result<(Vec<u8>, bool)> {
-    use tokio::io::AsyncReadExt;
-    let mut bytes = std::collections::VecDeque::with_capacity(limit);
-    let mut buffer = [0; 8192];
-    let mut truncated = false;
-    loop {
-        let read = input.read(&mut buffer).await?;
-        if read == 0 {
-            break;
-        }
-        let overflow = (bytes.len() + read).saturating_sub(limit);
-        if overflow != 0 {
-            truncated = true;
-            bytes.drain(..overflow.min(bytes.len()));
+        } else {
+            let page = source.read(request).await?;
+            let mut stdout = std::io::stdout().lock();
+            serde_json::to_writer(&mut stdout, &page)?;
+            stdout.write_all(b"\n")?;
+            stdout.flush()?;
         }
-        bytes.extend(&buffer[read.saturating_sub(limit)..read]);
+        Ok(())
     }
-    Ok((bytes.into(), truncated))
 }
diff --git a/products/maka/cli/src/deployment/service/macos.rs b/products/maka/cli/src/deployment/service/macos.rs
index cfe7aed21b..0ceebcd195 100644
--- a/products/maka/cli/src/deployment/service/macos.rs
+++ b/products/maka/cli/src/deployment/service/macos.rs
@@ -29,6 +29,7 @@ pub(super) struct Service {
     target: String,
     path: PathBuf,
     definition: String,
+    capture: Option<maka_host_runtime::logs::Source>,
 }
 
 struct Instance {
@@ -47,11 +48,18 @@ impl Service {
         let args = arguments(deployment, role)?
             .map(|arg| format!("<string>{}</string>", xml(arg)))
             .join("");
-        let log = super::super::directory(&deployment.root_id)?.join(if role == Role::Host {
-            "host.stderr.log"
+        let capture = if role == Role::Host {
+            Some(maka_host_runtime::logs::Source::for_root(
+                &deployment.root_id,
+                &maka_event_log::root::RootNamespaces::for_current_account()?,
+            )?)
         } else {
-            "update.stderr.log"
-        });
+            None
+        };
+        let log = match &capture {
+            Some(source) => source.path().to_owned(),
+            None => super::super::directory(&deployment.root_id)?.join("update.stderr.log"),
+        };
         let log = xml(log.to_str().ok_or("service log path must be UTF-8")?);
         let definition = if role == Role::Updater {
             format!(
@@ -68,6 +76,7 @@ impl Service {
             label,
             path,
             definition,
+            capture,
         })
     }
 
@@ -79,6 +88,9 @@ impl Service {
 
     pub fn prepare(&self) -> Result<(), HostError> {
         self.stop()?;
+        if let Some(source) = &self.capture {
+            drop(source.open_capture()?);
+        }
         publish(&self.path, &self.definition)?;
         checked(command("/bin/launchctl", &["enable", &self.target])?)?;
         checked(command(
diff --git a/products/maka/cli/src/logging.rs b/products/maka/cli/src/logging.rs
index 1ffc73d4ea..9f591e59e0 100644
--- a/products/maka/cli/src/logging.rs
+++ b/products/maka/cli/src/logging.rs
@@ -27,11 +27,94 @@ pub(super) fn initialize() -> Result<(), maka_host_runtime::HostError> {
         .try_init()
 }
 
+/// Dedicated Host processes keep stderr attached through final error reporting
+/// and runtime shutdown. The caller supplies an already validated append file.
+pub(super) fn capture(file: std::fs::File) -> std::io::Result<()> {
+    #[cfg(unix)]
+    {
+        use std::os::fd::{AsRawFd, IntoRawFd};
+        // A caller may have started with stderr closed, so opening the capture
+        // can itself allocate fd 2. Transfer that descriptor instead of closing
+        // it when File drops, and give it ordinary stdio inheritance semantics.
+        if file.as_raw_fd() == libc::STDERR_FILENO {
+            // SAFETY: the owned file is the live stderr descriptor.
+            if unsafe { libc::fcntl(libc::STDERR_FILENO, libc::F_SETFD, 0) } == -1 {
+                return Err(std::io::Error::last_os_error());
+            }
+            let _ = file.into_raw_fd();
+            return Ok(());
+        }
+        // SAFETY: both descriptors are valid; dup2 leaves stderr owning its own
+        // reference after the original File is dropped.
+        if unsafe { libc::dup2(file.as_raw_fd(), libc::STDERR_FILENO) } == -1 {
+            return Err(std::io::Error::last_os_error());
+        }
+        Ok(())
+    }
+    #[cfg(windows)]
+    {
+        use std::os::windows::io::AsRawHandle;
+        use windows_sys::Win32::System::Console::{STD_ERROR_HANDLE, SetStdHandle};
+        // SetStdHandle borrows the handle. Statics are not dropped at shutdown;
+        // the OS closes it after the last process error has been written.
+        static STDERR: std::sync::OnceLock<std::io::Result<std::fs::File>> =
+            std::sync::OnceLock::new();
+        STDERR
+            .get_or_init(|| {
+                // SAFETY: STDERR retains the owned file until process exit.
+                if unsafe { SetStdHandle(STD_ERROR_HANDLE, file.as_raw_handle()) } == 0 {
+                    Err(std::io::Error::last_os_error())
+                } else {
+                    Ok(file)
+                }
+            })
+            .as_ref()
+            .map(|_| ())
+            .map_err(|error| std::io::Error::new(error.kind(), error.to_string()))
+    }
+}
+
 #[cfg(test)]
 mod tests {
     use sqlx::{ConnectOptions, Connection};
     use std::{process::Command, time::Duration};
 
+    #[cfg(unix)]
+    #[test]
+    fn capture_keeps_the_file_open_when_stderr_started_closed() {
+        let directory = tempfile::tempdir().unwrap();
+        let path = directory.path().join("stderr.log");
+        let output = Command::new(std::env::current_exe().unwrap())
+            .args([
+                "--exact",
+                "logging::tests::closed_stderr_worker",
+                "--nocapture",
+            ])
+            .env("MAKA_LOGGING_CLOSED_STDERR", &path)
+            .output()
+            .unwrap();
+        assert!(output.status.success(), "{output:?}");
+        assert_eq!(
+            std::fs::read(path).unwrap(),
+            b"captured after closed stderr\n"
+        );
+    }
+
+    #[cfg(unix)]
+    #[test]
+    fn closed_stderr_worker() {
+        use std::os::fd::AsRawFd;
+        let Some(path) = std::env::var_os("MAKA_LOGGING_CLOSED_STDERR") else {
+            return;
+        };
+        // SAFETY: this dedicated subprocess owns its standard descriptors.
+        assert_eq!(unsafe { libc::close(libc::STDERR_FILENO) }, 0);
+        let file = std::fs::File::create(path).unwrap();
+        assert_eq!(file.as_raw_fd(), libc::STDERR_FILENO);
+        super::capture(file).unwrap();
+        eprintln!("captured after closed stderr");
+    }
+
     #[test]
     fn sqlx_warnings_reach_stderr_without_debug_queries() {
         for (mode, expected) in [("slow", true), ("ordinary", false)] {
diff --git a/products/maka/cli/src/serve.rs b/products/maka/cli/src/serve.rs
index 8fd774cff7..d21189d7df 100644
--- a/products/maka/cli/src/serve.rs
+++ b/products/maka/cli/src/serve.rs
@@ -57,6 +57,11 @@ pub(super) async fn run(
     if expected_root.is_some_and(|expected| expected != owner.root_id()) {
         return Err("service State Root identity changed".into());
     }
+    if expected_root.is_none() {
+        let logs = maka_host_runtime::logs::Source::from_owner(&owner)?;
+        eprintln!("Host diagnostics: {}", logs.path().display());
+        crate::logging::capture(logs.open_capture()?)?;
+    }
     let deployment = crate::deployment::admit(&owner, crate::deployment::Mode::Supervised).await?;
     if expected_root.is_some() && deployment.is_none() {
         return Err("service State Root must have a supervised deployment".into());
diff --git a/products/maka/cli/src/windows.rs b/products/maka/cli/src/windows.rs
index fc668ddbab..fd64ccb6d6 100644
--- a/products/maka/cli/src/windows.rs
+++ b/products/maka/cli/src/windows.rs
@@ -37,76 +37,6 @@ use windows_sys::Win32::System::{
 // the Host exits, terminating descendants without killing the Host during Drop.
 static OWNER: OnceLock<io::Result<OwnedHandle>> = OnceLock::new();
 
-// SetStdHandle borrows the file handle. Retain it until process exit, including
-// final error reporting and Tokio's shutdown of accepted blocking work.
-static SERVICE_STDERR: OnceLock<io::Result<std::fs::File>> = OnceLock::new();
-
-pub(super) fn service_stderr(directory: &std::path::Path) -> io::Result<()> {
-    SERVICE_STDERR
-        .get_or_init(|| open_service_stderr(directory))
-        .as_ref()
-        .map(|_| ())
-        .map_err(|error| io::Error::new(error.kind(), error.to_string()))
-}
-
-fn open_service_stderr(directory: &std::path::Path) -> io::Result<std::fs::File> {
-    use maka_event_log::root::windows::{
-        PrivateSecurity, file_identity, open_nofollow, validate_private,
-    };
-    use std::os::windows::{ffi::OsStrExt, fs::MetadataExt};
-    use windows_sys::Win32::{
-        Foundation::{GENERIC_READ, INVALID_HANDLE_VALUE},
-        Storage::FileSystem::{
-            CreateFileW, FILE_APPEND_DATA, FILE_ATTRIBUTE_REPARSE_POINT,
-            FILE_FLAG_OPEN_REPARSE_POINT, FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE,
-            OPEN_ALWAYS,
-        },
-        System::Console::{STD_ERROR_HANDLE, SetStdHandle},
-    };
-
-    let parent = open_nofollow(directory, false)?;
-    validate_private(&parent)?;
-    if !parent.metadata()?.is_dir() {
-        return Err(io::Error::other("service deployment is not a directory"));
-    }
-    let path = directory.join("host.stderr.log");
-    let name: Vec<_> = path.as_os_str().encode_wide().chain(Some(0)).collect();
-    let security = PrivateSecurity::current_account()?;
-    // SAFETY: name and private descriptor live through the call. Append-only
-    // writes preserve concurrent startup diagnostics without following reparse points.
-    let handle = unsafe {
-        CreateFileW(
-            name.as_ptr(),
-            GENERIC_READ | FILE_APPEND_DATA,
-            FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
-            &security.attributes(),
-            OPEN_ALWAYS,
-            FILE_FLAG_OPEN_REPARSE_POINT,
-            ptr::null_mut(),
-        )
-    };
-    if handle == INVALID_HANDLE_VALUE {
-        return Err(io::Error::last_os_error());
-    }
-    // SAFETY: CreateFileW returned one owned file handle.
-    let file = unsafe { std::fs::File::from_raw_handle(handle) };
-    let metadata = file.metadata()?;
-    if !metadata.is_file()
-        || metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0
-        || file_identity(&file)?.links != 1
-    {
-        return Err(io::Error::other(
-            "service log is not a regular, singly linked file",
-        ));
-    }
-    validate_private(&file)?;
-    // SAFETY: SERVICE_STDERR owns the returned file until the process exits.
-    if unsafe { SetStdHandle(STD_ERROR_HANDLE, file.as_raw_handle()) } == 0 {
-        return Err(io::Error::last_os_error());
-    }
-    Ok(file)
-}
-
 pub(super) fn own_process_tree() -> io::Result<()> {
     OWNER
         .get_or_init(create)
diff --git a/products/maka/cli/tests/integration/candidate.rs b/products/maka/cli/tests/integration/candidate.rs
index 34f99e0340..59ec2756e0 100644
--- a/products/maka/cli/tests/integration/candidate.rs
+++ b/products/maka/cli/tests/integration/candidate.rs
@@ -358,6 +358,7 @@ impl Drop for CandidateFixture {
             std::thread::sleep(Duration::from_millis(20));
         }
         let _ = fs::remove_file(&self.registration);
+        let _ = fs::remove_file(control.join("host.stderr.log"));
         let deployment = namespaces
             .ownership
             .parent()
diff --git a/products/maka/cli/tests/integration/candidate/startup.rs b/products/maka/cli/tests/integration/candidate/startup.rs
index d313a5134a..70cb9af479 100644
--- a/products/maka/cli/tests/integration/candidate/startup.rs
+++ b/products/maka/cli/tests/integration/candidate/startup.rs
@@ -19,6 +19,51 @@
 
 use super::*;
 
+#[test]
+fn host_logs_reads_captured_stderr_without_an_installed_deployment() {
+    use base64::Engine;
+
+    let directory = tempfile::tempdir().unwrap();
+    let fixture = CandidateFixture::new(directory.path().join("root"));
+    let path = fixture
+        .registration
+        .parent()
+        .unwrap()
+        .join("host.stderr.log");
+    let mut options = fs::OpenOptions::new();
+    options.write(true).create_new(true);
+    #[cfg(unix)]
+    {
+        use std::os::unix::fs::OpenOptionsExt;
+        options.mode(0o600);
+    }
+    let mut capture = options.open(path).unwrap();
+    capture.write_all(b"pre-Ready host failure\n\xff").unwrap();
+    drop(capture);
+
+    let output = Command::new(env!("CARGO_BIN_EXE_maka"))
+        .args(["host", "logs", "--root-id", &fixture.root_id])
+        .output()
+        .unwrap();
+    assert!(
+        output.status.success(),
+        "uninstalled Root's captured stderr must remain readable: {}",
+        String::from_utf8_lossy(&output.stderr)
+    );
+    let page: Value = serde_json::from_slice(&output.stdout).unwrap();
+    assert_eq!(page["kind"], "data");
+    assert_eq!(
+        base64::engine::general_purpose::STANDARD
+            .decode(page["bytes"].as_str().unwrap())
+            .unwrap(),
+        b"pre-Ready host failure\n\xff"
+    );
+    assert_eq!(page["reset"], Value::Null);
+    assert_eq!(page["truncated"], false);
+    assert_eq!(page["hasMore"], false);
+    assert!(!page["cursor"].as_str().unwrap().is_empty());
+}
+
 fn start(directory: &Path) -> CandidateFixture {
     let mut fixture = CandidateFixture::new(directory.join("root"));
     fixture.child = Some(
@@ -106,3 +151,279 @@ fn candidate_owner_eof_without_an_accepted_client_drains_startup() {
         .unwrap(),
     );
 }
+
+#[test]
+fn detached_candidate_captures_pre_ready_and_released_owner_failures() {
+    use base64::Engine;
+    use futures_util::FutureExt;
+    use maka_host_runtime::logs::Source;
+    use tokio::io::AsyncWriteExt;
+
+    let directory = tempfile::tempdir().unwrap();
+    let fixture = CandidateFixture::new(directory.path().join("root"));
+    let runtime = tokio::runtime::Runtime::new().unwrap();
+    runtime.block_on(async {
+        for released in [false, true] {
+            let owner = RootOwner::open(
+                &fixture.root,
+                &RootNamespaces::for_current_account().unwrap(),
+            )
+            .unwrap();
+            let source = Source::from_owner(&owner).unwrap();
+            let capture = source.open_capture().unwrap();
+            drop(owner);
+            let expected_id = if released {
+                fixture.root_id.as_str()
+            } else {
+                "wrong-root"
+            };
+            let mut child = maka_process::detached::spawn(
+                Path::new(env!("CARGO_BIN_EXE_maka")),
+                &[
+                    "host",
+                    "candidate",
+                    "--root",
+                    fixture.root.to_str().unwrap(),
+                    "--expected-root-id",
+                    expected_id,
+                    "--startup-attempt-id",
+                    &uuid::Uuid::new_v4().to_string(),
+                    "--owner-stdin",
+                    "--initial-connection-timeout-ms",
+                    "30000",
+                ],
+                Some(capture),
+            )
+            .await
+            .unwrap();
+            let mut original_registration = None;
+            let outcome = std::panic::AssertUnwindSafe(async {
+                tokio::time::timeout(Duration::from_secs(15), async {
+                    if released {
+                        loop {
+                            if fixture.registration.exists() {
+                                break;
+                            }
+                            assert!(
+                                child.try_wait().unwrap().is_none(),
+                                "candidate exited before Ready"
+                            );
+                            tokio::time::sleep(Duration::from_millis(20)).await;
+                        }
+                        let found = discovery::read(&fixture.root).unwrap();
+                        original_registration = Some(fs::read(&fixture.registration).unwrap());
+                        let mut client = connect(&fixture.root).await;
+                        let mut stdin = child.stdin.take().unwrap();
+                        stdin
+                            .write_all(b"{\"kind\":\"runtime-host-launch-owner-release\"}\n")
+                            .await
+                            .unwrap();
+                        drop(stdin);
+                        assert!(
+                            !Client::status(&found.endpoint, Some(client.epoch()))
+                                .await
+                                .unwrap()
+                                .closing
+                        );
+                        // A real shutdown failure after launcher release must still reach stderr.
+                        fs::write(&fixture.registration, b"not a registration").unwrap();
+                        client.close().await.unwrap();
+                        assert!(
+                            Client::retire_with_options(
+                                &found.endpoint,
+                                found.host_epoch,
+                                false,
+                                false
+                            )
+                            .await
+                            .unwrap()
+                        );
+                    }
+                    let status = child.wait().await.unwrap();
+                    assert!(
+                        !status.success(),
+                        "injected startup/shutdown failure was not reported"
+                    );
+                })
+                .await
+                .expect("captured candidate did not settle");
+                let output = Command::new(env!("CARGO_BIN_EXE_maka"))
+                    .args(["host", "logs", "--root-id", &fixture.root_id])
+                    .output()
+                    .unwrap();
+                assert!(
+                    output.status.success(),
+                    "{}",
+                    String::from_utf8_lossy(&output.stderr)
+                );
+                let page: Value = serde_json::from_slice(&output.stdout).unwrap();
+                let bytes = base64::engine::general_purpose::STANDARD
+                    .decode(page["bytes"].as_str().unwrap())
+                    .unwrap();
+                let log = String::from_utf8_lossy(&bytes);
+                assert!(
+                    log.contains(if released {
+                        "could not remove Host registration"
+                    } else {
+                        "candidate State Root identity mismatch"
+                    }),
+                    "{log}"
+                );
+            })
+            .catch_unwind()
+            .await;
+            // Reap the exact spawned child even when an assertion or readiness wait fails.
+            child.start_kill().unwrap();
+            tokio::time::timeout(Duration::from_secs(5), child.wait())
+                .await
+                .unwrap()
+                .unwrap();
+            if let Some(bytes) = original_registration {
+                fs::write(&fixture.registration, bytes).unwrap();
+            }
+            if let Err(panic) = outcome {
+                std::panic::resume_unwind(panic);
+            }
+        }
+    });
+}
+
+#[cfg(unix)]
+#[test]
+fn host_logs_follow_streams_uninstalled_root_and_exits_with_a_full_stdout_pipe() {
+    use base64::Engine;
+    use std::{
+        io::Read,
+        os::fd::{AsRawFd, FromRawFd},
+    };
+
+    let directory = tempfile::tempdir().unwrap();
+    let mut fixture = CandidateFixture::new(directory.path().join("root"));
+    let mut descriptors = [-1; 2];
+    // This test owns both ends; retaining the writer permits observing actual backpressure.
+    assert_eq!(unsafe { libc::pipe(descriptors.as_mut_ptr()) }, 0);
+    for descriptor in descriptors {
+        assert_eq!(
+            unsafe { libc::fcntl(descriptor, libc::F_SETFD, libc::FD_CLOEXEC) },
+            0
+        );
+    }
+    let mut reader = unsafe { fs::File::from_raw_fd(descriptors[0]) };
+    let writer = unsafe { fs::File::from_raw_fd(descriptors[1]) };
+    let flags = unsafe { libc::fcntl(reader.as_raw_fd(), libc::F_GETFL) };
+    assert!(flags >= 0);
+    assert_eq!(
+        unsafe { libc::fcntl(reader.as_raw_fd(), libc::F_SETFL, flags | libc::O_NONBLOCK) },
+        0
+    );
+    #[cfg(target_os = "linux")]
+    assert!(unsafe { libc::fcntl(writer.as_raw_fd(), libc::F_SETPIPE_SZ, 4096) } >= 4096);
+    fixture.child = Some(
+        Command::new(env!("CARGO_BIN_EXE_maka"))
+            .args([
+                "--timeout-ms",
+                "1",
+                "host",
+                "logs",
+                "--root-id",
+                &fixture.root_id,
+                "--follow",
+            ])
+            .stdout(Stdio::from(writer.try_clone().unwrap()))
+            .stderr(Stdio::inherit())
+            .spawn()
+            .unwrap(),
+    );
+
+    fn page(reader: &mut fs::File, fixture: &mut CandidateFixture) -> Value {
+        let deadline = Instant::now() + Duration::from_secs(10);
+        let mut bytes = Vec::new();
+        loop {
+            let mut byte = [0];
+            match reader.read(&mut byte) {
+                Ok(1) if byte[0] == b'\n' => return serde_json::from_slice(&bytes).unwrap(),
+                Ok(1) => bytes.push(byte[0]),
+                Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
+                    assert!(
+                        fixture
+                            .child
+                            .as_mut()
+                            .unwrap()
+                            .try_wait()
+                            .unwrap()
+                            .is_none(),
+                        "follow exited before its next page"
+                    );
+                    assert!(Instant::now() < deadline, "follow page timed out");
+                    let mut poll = libc::pollfd {
+                        fd: reader.as_raw_fd(),
+                        events: libc::POLLIN,
+                        revents: 0,
+                    };
+                    assert!(unsafe { libc::poll(&mut poll, 1, 100) } >= 0);
+                }
+                other => panic!("unexpected follow read: {other:?}"),
+            }
+        }
+    }
+
+    assert_eq!(
+        page(&mut reader, &mut fixture),
+        serde_json::json!({"kind":"missing"})
+    );
+    let owner = RootOwner::open(
+        &fixture.root,
+        &RootNamespaces::for_current_account().unwrap(),
+    )
+    .unwrap();
+    let source = maka_host_runtime::logs::Source::from_owner(&owner).unwrap();
+    let mut capture = source.open_capture().unwrap();
+    drop(owner);
+    let initial = page(&mut reader, &mut fixture);
+    assert_eq!(initial["kind"], "data");
+    assert_eq!(initial["bytes"], "");
+    capture.write_all(b"after creation\n\xff").unwrap();
+    let appended = page(&mut reader, &mut fixture);
+    assert_eq!(
+        base64::engine::general_purpose::STANDARD
+            .decode(appended["bytes"].as_str().unwrap())
+            .unwrap(),
+        b"after creation\n\xff"
+    );
+    assert_eq!(appended["reset"], Value::Null);
+
+    // More than the pipe, one blocking write, and the bounded async bridge can hold.
+    capture.write_all(&vec![b'x'; 1024 * 1024]).unwrap();
+    let deadline = Instant::now() + Duration::from_secs(10);
+    loop {
+        let mut poll = libc::pollfd {
+            fd: writer.as_raw_fd(),
+            events: libc::POLLOUT,
+            revents: 0,
+        };
+        assert!(unsafe { libc::poll(&mut poll, 1, 0) } >= 0);
+        if poll.revents & libc::POLLOUT == 0 {
+            break;
+        }
+        assert!(
+            fixture
+                .child
+                .as_mut()
+                .unwrap()
+                .try_wait()
+                .unwrap()
+                .is_none()
+        );
+        assert!(
+            Instant::now() < deadline,
+            "follow never filled its stdout pipe"
+        );
+        std::thread::park_timeout(Duration::from_millis(2));
+    }
+    // The exact owned child must service cancellation without waiting for this reader.
+    assert_eq!(
+        unsafe { libc::kill(fixture.child.as_ref().unwrap().id() as i32, libc::SIGTERM) },
+        0
+    );
+    assert!(fixture.wait_for_exit().success());
+}
diff --git a/products/maka/cli/tests/integration/terminal/bundled_product.rs b/products/maka/cli/tests/integration/terminal/bundled_product.rs
index 49af4e4d56..02792706dc 100644
--- a/products/maka/cli/tests/integration/terminal/bundled_product.rs
+++ b/products/maka/cli/tests/integration/terminal/bundled_product.rs
@@ -24,6 +24,7 @@ use serde_json::json;
 use unicode_width::UnicodeWidthStr;
 
 mod chat;
+mod host_logs;
 
 #[track_caller]
 fn click(tui: &mut Pty, label: &str) {
diff --git a/products/maka/cli/tests/integration/terminal/bundled_product/host_logs.rs b/products/maka/cli/tests/integration/terminal/bundled_product/host_logs.rs
new file mode 100644
index 0000000000..05a01975b0
--- /dev/null
+++ b/products/maka/cli/tests/integration/terminal/bundled_product/host_logs.rs
@@ -0,0 +1,132 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use super::*;
+use maka_agent_api::session::SessionConfiguration;
+use maka_event_log::{EventLog, usage::Query};
+use maka_host_runtime::logs::Source;
+use std::sync::Arc;
+
+#[test]
+#[ignore = "real default product Host log grants, no-session Diagnostics and live follow in a native PTY"]
+fn default_product_host_logs_without_session_read_follow_and_release_roots() {
+    let directory = tempfile::tempdir().unwrap();
+    let workspace = directory.path().join("workspace");
+    std::fs::create_dir(&workspace).unwrap();
+    let host = directory.path().join("host");
+    let host_guard = HostRootGuard::new(&host);
+    let ui = directory.path().join("ui");
+    let namespaces = RootNamespaces::for_current_account().unwrap();
+    let owner = RootOwner::create(&host, &namespaces).unwrap();
+    let source = Source::from_owner(&owner).unwrap();
+    let mut capture = source.open_capture().unwrap();
+    drop(owner);
+    let tag = uuid::Uuid::new_v4().simple().to_string();
+    let first = format!("HOST-LOG-{}-ONE", &tag[..8]);
+    let second = format!("HOST-LOG-{}-TWO 中文", &tag[..8]);
+    writeln!(capture, "{first}").unwrap();
+    capture.flush().unwrap();
+    let configuration = directory.path().join("installation.json");
+    std::fs::write(
+        &configuration,
+        serde_json::to_vec(&json!({
+            "presentation": "tui", "locale": "en", "scheme": "dark",
+            "hostRoot": host,
+            "agent": {
+                "workspaceRoots": [{"label": "Workspace", "path": workspace}],
+                "tools": [],
+                "permissions": {"access": "read_only", "requestPermissions": true}
+            }
+        }))
+        .unwrap(),
+    )
+    .unwrap();
+    let mut tui = Pty::spawn(&[
+        "application",
+        "launch",
+        "--root",
+        ui.to_str().unwrap(),
+        "--configuration",
+        configuration.to_str().unwrap(),
+    ]);
+    tui.wait_until_with_timeout(
+        |screen| screen.contains("Settings") && screen.contains("Start a task."),
+        Duration::from_secs(60),
+    );
+    assert!(!tui.screen.snapshot().unwrap().screen.contains("Message…"));
+    tui.send(b"\x0b");
+    tui.wait_for("Close commands");
+    tui.send(b"Diagnostics");
+    tui.wait_for("› Diagnostics");
+    tui.send(b"\r");
+    tui.wait_for("Host logs");
+    tui.wait_for(&first);
+    settings_evidence(&tui, "host-logs-read");
+    click(&mut tui, "Follow");
+    tui.wait_for("Stop");
+    writeln!(capture, "{second}").unwrap();
+    capture.flush().unwrap();
+    tui.wait_for(&second);
+    assert!(tui.screen.snapshot().unwrap().screen.contains(&first));
+    settings_evidence(&tui, "host-logs-follow");
+    click(&mut tui, "Stop");
+    tui.wait_for("Follow");
+    click(&mut tui, "Close");
+    tui.wait_until(|screen| !screen.contains(&first) && !screen.contains("Follow"));
+    tui.send(b"\x11");
+    tui.finish();
+    assert!(!String::from_utf8_lossy(&tui.output).contains("invalid UI activation"));
+    assert!(!String::from_utf8_lossy(&tui.output).contains("invalid UI batch"));
+    drop(capture);
+    drop(RootOwner::open(&ui, &namespaces).unwrap());
+    host_guard.stop();
+    let owner = Arc::new(RootOwner::open(&host, &namespaces).unwrap());
+    tokio::runtime::Runtime::new().unwrap().block_on(async {
+        let store = EventLog::for_root(owner).await.unwrap();
+        assert!(
+            store
+                .list_sessions::<SessionConfiguration>(None, None, 1)
+                .await
+                .unwrap()
+                .sessions
+                .is_empty(),
+            "Host diagnostics must not create a conversation"
+        );
+        assert!(
+            store
+                .model_attempts(
+                    Query {
+                        from: 0.0,
+                        to: 253402300799999.0,
+                        session_id: None,
+                        through: None,
+                    },
+                    0,
+                    1
+                )
+                .await
+                .unwrap()
+                .attempts
+                .is_empty(),
+            "Host diagnostics must not invoke a model"
+        );
+        store.close().await.unwrap();
+    });
+    drop(RootOwner::open(&host, &namespaces).unwrap());
+}
diff --git a/tools/schema/src/lib.rs b/tools/schema/src/lib.rs
index f808bc6d57..44ea8eace7 100644
--- a/tools/schema/src/lib.rs
+++ b/tools/schema/src/lib.rs
@@ -33,6 +33,8 @@ pub fn catalog() -> Value {
     use composition::PresentationTarget;
     let mut g = SchemaGenerator::default();
     let roots = vec![
+        root::<maka_host_runtime::logs::HostLogRequest>(&mut g),
+        root::<maka_host_runtime::logs::HostLogPage>(&mut g),
         root::<maka_ui_authoring::contract::Command>(&mut g),
         root::<maka_ui_authoring::contract::Claim>(&mut g),
         root::<maka_ui_authoring::contract::State>(&mut g),
@@ -417,6 +419,8 @@ pub fn catalog() -> Value {
         "workspaceWeb": maka_web_api::contract(),
         "workspaceJev": maka_workspace_api::jev::contract(),
     });
+    contracts["hostLogs"] = serde_json::to_value(maka_host_runtime::application::logs::contract())
+        .expect("Host logs contract");
     contracts["workspaceComputer"] =
         serde_json::to_value(maka_workspace_api::computer::contract()).expect("Computer contract");
     contracts["settingsSetup"] =
@@ -466,6 +470,8 @@ pub fn catalog() -> Value {
         "settings": maka_settings_native::native(),
         "settingsAuthentication": maka_settings_native::native_authentication(),
     });
+    native["hostLogs"] = serde_json::to_value(maka_host_runtime::application::logs::native())
+        .expect("Host logs native");
     native["settingsSetup"] =
         serde_json::to_value(maka_settings_native::native_setup()).expect("setup native");
     json!({


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org