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 07:20:27 UTC

(maka) branch rust-preview updated: feat(terminal): expose ordered raw PTY output

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 6746b3defe feat(terminal): expose ordered raw PTY output
6746b3defe is described below

commit 6746b3defe2b19da7b3635682ed17355ea1f565a
Author: Wang <m4...@gmail.com>
AuthorDate: Sat Oct 17 15:20:13 2026 +0800

    feat(terminal): expose ordered raw PTY output
    
    Preserve native bytes before screen decoding and order resize events in the
    existing bounded stream. Expose target-scoped reads and subscriptions with
    explicit gaps and retention limits while preserving controller custody.
    
    Generated-by: Codex
---
 Cargo.lock                                         |   1 +
 packages/plugin-sdk/src/contracts.ts               | 456 +++++++++++++++++++++
 packages/plugin-sdk/src/generated/runtime.ts       |  55 +++
 platform/host-runtime/src/shell.rs                 |   2 +-
 platform/host-runtime/src/shell/handle.rs          |  15 +-
 platform/host-runtime/src/shell/output.rs          | 401 +++++++++++-------
 platform/host-runtime/src/shell/terminal.rs        |  60 ++-
 platform/host-runtime/src/shell/worker/control.rs  |  10 +-
 plugins/agent/native/src/owner/tests/terminal.rs   | 210 ++++++++++
 plugins/workspace/api/README.md                    |   2 +
 plugins/workspace/api/README.zh-CN.md              |   2 +
 plugins/workspace/api/src/terminal.rs              |  66 +++
 .../workspace-processes/native/Cargo.toml          |   1 +
 .../workspace/workspace-processes/native/README.md |   2 +
 .../workspace-processes/native/README.zh-CN.md     |   2 +
 .../workspace-processes/native/src/terminal.rs     |   5 +
 .../native/src/terminal/output.rs                  | 106 +++++
 products/maka/cli/notices.json                     |   8 +-
 tools/schema/src/lib.rs                            |   6 +
 19 files changed, 1245 insertions(+), 165 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock
index b54eb56e9d..e8e725401c 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -8918,6 +8918,7 @@ dependencies = [
 name = "maka-processes-native"
 version = "0.2.0"
 dependencies = [
+ "base64 0.22.1",
  "futures-util",
  "jsonschema 0.56.0",
  "maka-agent-api",
diff --git a/packages/plugin-sdk/src/contracts.ts b/packages/plugin-sdk/src/contracts.ts
index ce17455fd4..0ad2d15d1e 100644
--- a/packages/plugin-sdk/src/contracts.ts
+++ b/packages/plugin-sdk/src/contracts.ts
@@ -37010,6 +37010,234 @@ export const contracts = {
           },
           resources: {},
         },
+        readOutput: {
+          mode: 'query',
+          input: {
+            type: 'object',
+            additionalProperties: false,
+            properties: {
+              target: {
+                $ref: '#/$defs/TerminalTarget',
+              },
+              after: {
+                type: 'integer',
+                format: 'uint64',
+                minimum: 0,
+                maximum: 9007199254740991,
+                default: 0,
+              },
+            },
+            required: ['target'],
+            description: "Cursor belongs to this original target's ordered Data/Resize stream.",
+            title: 'TerminalOutputRequest',
+            $schema: 'https://json-schema.org/draft/2020-12/schema',
+            $defs: {
+              TerminalTarget: {
+                type: 'object',
+                additionalProperties: false,
+                properties: {
+                  sessionId: {
+                    type: 'string',
+                  },
+                  ref: {
+                    type: 'string',
+                  },
+                },
+                required: ['sessionId', 'ref'],
+              },
+            },
+          },
+          output: {
+            oneOf: [
+              {
+                type: 'object',
+                additionalProperties: false,
+                properties: {
+                  value: {
+                    $ref: '#/$defs/TerminalOutputEvent',
+                  },
+                  kind: {
+                    type: 'string',
+                    const: 'completed',
+                  },
+                },
+                required: ['kind', 'value'],
+              },
+              {
+                type: 'object',
+                additionalProperties: false,
+                properties: {
+                  message: {
+                    type: 'string',
+                  },
+                  kind: {
+                    type: 'string',
+                    const: 'failed',
+                  },
+                },
+                required: ['kind', 'message'],
+              },
+            ],
+            title: 'TerminalOutcome_TerminalOutputEvent',
+            $schema: 'https://json-schema.org/draft/2020-12/schema',
+            $defs: {
+              TerminalOutputEvent: {
+                oneOf: [
+                  {
+                    type: 'object',
+                    additionalProperties: false,
+                    properties: {
+                      sequence: {
+                        type: 'integer',
+                        format: 'uint64',
+                        minimum: 1,
+                        maximum: 9007199254740991,
+                      },
+                      size: {
+                        $ref: '#/$defs/TerminalSize',
+                      },
+                      dataBase64: {
+                        type: 'string',
+                        minLength: 1,
+                        maxLength: 21848,
+                      },
+                      kind: {
+                        type: 'string',
+                        const: 'data',
+                      },
+                    },
+                    required: ['kind', 'sequence', 'size', 'dataBase64'],
+                  },
+                  {
+                    type: 'object',
+                    additionalProperties: false,
+                    properties: {
+                      sequence: {
+                        type: 'integer',
+                        format: 'uint64',
+                        minimum: 1,
+                        maximum: 9007199254740991,
+                      },
+                      size: {
+                        $ref: '#/$defs/TerminalSize',
+                      },
+                      kind: {
+                        type: 'string',
+                        const: 'resize',
+                      },
+                    },
+                    required: ['kind', 'sequence', 'size'],
+                  },
+                  {
+                    type: 'object',
+                    additionalProperties: false,
+                    properties: {
+                      after: {
+                        type: 'integer',
+                        format: 'uint64',
+                        minimum: 0,
+                        maximum: 9007199254740991,
+                      },
+                      firstAvailable: {
+                        type: 'integer',
+                        format: 'uint64',
+                        minimum: 1,
+                        maximum: 9007199254740991,
+                      },
+                      kind: {
+                        type: 'string',
+                        const: 'gap',
+                      },
+                    },
+                    required: ['kind', 'after', 'firstAvailable'],
+                  },
+                  {
+                    type: 'object',
+                    additionalProperties: false,
+                    properties: {
+                      after: {
+                        type: 'integer',
+                        format: 'uint64',
+                        minimum: 0,
+                        maximum: 9007199254740991,
+                      },
+                      size: {
+                        $ref: '#/$defs/TerminalSize',
+                      },
+                      kind: {
+                        type: 'string',
+                        const: 'pending',
+                      },
+                    },
+                    required: ['kind', 'after', 'size'],
+                  },
+                  {
+                    type: 'object',
+                    additionalProperties: false,
+                    properties: {
+                      after: {
+                        type: 'integer',
+                        format: 'uint64',
+                        minimum: 0,
+                        maximum: 9007199254740991,
+                      },
+                      size: {
+                        $ref: '#/$defs/TerminalSize',
+                      },
+                      kind: {
+                        type: 'string',
+                        const: 'closed',
+                      },
+                    },
+                    required: ['kind', 'after', 'size'],
+                  },
+                  {
+                    type: 'object',
+                    additionalProperties: false,
+                    properties: {
+                      reason: {
+                        $ref: '#/$defs/TerminalOutputUnavailable',
+                      },
+                      kind: {
+                        type: 'string',
+                        const: 'unavailable',
+                      },
+                    },
+                    required: ['kind', 'reason'],
+                  },
+                ],
+                description:
+                  'Ephemeral output observation; Closed is not a process cleanup receipt.',
+              },
+              TerminalSize: {
+                type: 'object',
+                additionalProperties: false,
+                properties: {
+                  cols: {
+                    type: 'integer',
+                    format: 'uint16',
+                    minimum: 2,
+                    maximum: 240,
+                  },
+                  rows: {
+                    type: 'integer',
+                    format: 'uint16',
+                    minimum: 1,
+                    maximum: 100,
+                  },
+                },
+                required: ['cols', 'rows'],
+                description:
+                  'Shared by native PTY allocation, input validation and screen rendering.',
+              },
+              TerminalOutputUnavailable: {
+                type: 'string',
+                enum: ['not_retained'],
+              },
+            },
+          },
+          resources: {},
+        },
         release: {
           mode: 'mutation',
           input: {
@@ -38505,6 +38733,234 @@ export const contracts = {
           },
           resources: {},
         },
+        watchOutput: {
+          mode: 'subscription',
+          input: {
+            type: 'object',
+            additionalProperties: false,
+            properties: {
+              target: {
+                $ref: '#/$defs/TerminalTarget',
+              },
+              after: {
+                type: 'integer',
+                format: 'uint64',
+                minimum: 0,
+                maximum: 9007199254740991,
+                default: 0,
+              },
+            },
+            required: ['target'],
+            description: "Cursor belongs to this original target's ordered Data/Resize stream.",
+            title: 'TerminalOutputRequest',
+            $schema: 'https://json-schema.org/draft/2020-12/schema',
+            $defs: {
+              TerminalTarget: {
+                type: 'object',
+                additionalProperties: false,
+                properties: {
+                  sessionId: {
+                    type: 'string',
+                  },
+                  ref: {
+                    type: 'string',
+                  },
+                },
+                required: ['sessionId', 'ref'],
+              },
+            },
+          },
+          output: {
+            oneOf: [
+              {
+                type: 'object',
+                additionalProperties: false,
+                properties: {
+                  value: {
+                    $ref: '#/$defs/TerminalOutputEvent',
+                  },
+                  kind: {
+                    type: 'string',
+                    const: 'completed',
+                  },
+                },
+                required: ['kind', 'value'],
+              },
+              {
+                type: 'object',
+                additionalProperties: false,
+                properties: {
+                  message: {
+                    type: 'string',
+                  },
+                  kind: {
+                    type: 'string',
+                    const: 'failed',
+                  },
+                },
+                required: ['kind', 'message'],
+              },
+            ],
+            title: 'TerminalOutcome_TerminalOutputEvent',
+            $schema: 'https://json-schema.org/draft/2020-12/schema',
+            $defs: {
+              TerminalOutputEvent: {
+                oneOf: [
+                  {
+                    type: 'object',
+                    additionalProperties: false,
+                    properties: {
+                      sequence: {
+                        type: 'integer',
+                        format: 'uint64',
+                        minimum: 1,
+                        maximum: 9007199254740991,
+                      },
+                      size: {
+                        $ref: '#/$defs/TerminalSize',
+                      },
+                      dataBase64: {
+                        type: 'string',
+                        minLength: 1,
+                        maxLength: 21848,
+                      },
+                      kind: {
+                        type: 'string',
+                        const: 'data',
+                      },
+                    },
+                    required: ['kind', 'sequence', 'size', 'dataBase64'],
+                  },
+                  {
+                    type: 'object',
+                    additionalProperties: false,
+                    properties: {
+                      sequence: {
+                        type: 'integer',
+                        format: 'uint64',
+                        minimum: 1,
+                        maximum: 9007199254740991,
+                      },
+                      size: {
+                        $ref: '#/$defs/TerminalSize',
+                      },
+                      kind: {
+                        type: 'string',
+                        const: 'resize',
+                      },
+                    },
+                    required: ['kind', 'sequence', 'size'],
+                  },
+                  {
+                    type: 'object',
+                    additionalProperties: false,
+                    properties: {
+                      after: {
+                        type: 'integer',
+                        format: 'uint64',
+                        minimum: 0,
+                        maximum: 9007199254740991,
+                      },
+                      firstAvailable: {
+                        type: 'integer',
+                        format: 'uint64',
+                        minimum: 1,
+                        maximum: 9007199254740991,
+                      },
+                      kind: {
+                        type: 'string',
+                        const: 'gap',
+                      },
+                    },
+                    required: ['kind', 'after', 'firstAvailable'],
+                  },
+                  {
+                    type: 'object',
+                    additionalProperties: false,
+                    properties: {
+                      after: {
+                        type: 'integer',
+                        format: 'uint64',
+                        minimum: 0,
+                        maximum: 9007199254740991,
+                      },
+                      size: {
+                        $ref: '#/$defs/TerminalSize',
+                      },
+                      kind: {
+                        type: 'string',
+                        const: 'pending',
+                      },
+                    },
+                    required: ['kind', 'after', 'size'],
+                  },
+                  {
+                    type: 'object',
+                    additionalProperties: false,
+                    properties: {
+                      after: {
+                        type: 'integer',
+                        format: 'uint64',
+                        minimum: 0,
+                        maximum: 9007199254740991,
+                      },
+                      size: {
+                        $ref: '#/$defs/TerminalSize',
+                      },
+                      kind: {
+                        type: 'string',
+                        const: 'closed',
+                      },
+                    },
+                    required: ['kind', 'after', 'size'],
+                  },
+                  {
+                    type: 'object',
+                    additionalProperties: false,
+                    properties: {
+                      reason: {
+                        $ref: '#/$defs/TerminalOutputUnavailable',
+                      },
+                      kind: {
+                        type: 'string',
+                        const: 'unavailable',
+                      },
+                    },
+                    required: ['kind', 'reason'],
+                  },
+                ],
+                description:
+                  'Ephemeral output observation; Closed is not a process cleanup receipt.',
+              },
+              TerminalSize: {
+                type: 'object',
+                additionalProperties: false,
+                properties: {
+                  cols: {
+                    type: 'integer',
+                    format: 'uint16',
+                    minimum: 2,
+                    maximum: 240,
+                  },
+                  rows: {
+                    type: 'integer',
+                    format: 'uint16',
+                    minimum: 1,
+                    maximum: 100,
+                  },
+                },
+                required: ['cols', 'rows'],
+                description:
+                  'Shared by native PTY allocation, input validation and screen rendering.',
+              },
+              TerminalOutputUnavailable: {
+                type: 'string',
+                enum: ['not_retained'],
+              },
+            },
+          },
+          resources: {},
+        },
       },
     },
   },
diff --git a/packages/plugin-sdk/src/generated/runtime.ts b/packages/plugin-sdk/src/generated/runtime.ts
index 8e2d776df4..87570497a5 100644
--- a/packages/plugin-sdk/src/generated/runtime.ts
+++ b/packages/plugin-sdk/src/generated/runtime.ts
@@ -288,6 +288,10 @@ export type RuntimeProtocol =
   | WorkspaceProcessConfiguration
   | TerminalList
   | TerminalTarget
+  | TerminalOutputRequest
+  | TerminalOutputEvent
+  | TerminalOutputUnavailable
+  | TerminalOutcome_TerminalOutputEvent
   | TerminalStart
   | TerminalController
   | TerminalControl
@@ -4392,6 +4396,50 @@ export type FormResult =
     };
 export type QuestionRejection = 'invalid_input' | 'unavailable';
 export type WorkspaceProcessSessionKind = 'agent' | 'workhub';
+/**
+ * Ephemeral output observation; Closed is not a process cleanup receipt.
+ */
+export type TerminalOutputEvent =
+  | {
+      sequence: number;
+      size: TerminalSize;
+      dataBase64: string;
+      kind: 'data';
+    }
+  | {
+      sequence: number;
+      size: TerminalSize;
+      kind: 'resize';
+    }
+  | {
+      after: number;
+      firstAvailable: number;
+      kind: 'gap';
+    }
+  | {
+      after: number;
+      size: TerminalSize;
+      kind: 'pending';
+    }
+  | {
+      after: number;
+      size: TerminalSize;
+      kind: 'closed';
+    }
+  | {
+      reason: TerminalOutputUnavailable;
+      kind: 'unavailable';
+    };
+export type TerminalOutputUnavailable = 'not_retained';
+export type TerminalOutcome_TerminalOutputEvent =
+  | {
+      value: TerminalOutputEvent;
+      kind: 'completed';
+    }
+  | {
+      message: string;
+      kind: 'failed';
+    };
 export type Ownership =
   | {
       kind: 'local';
@@ -9969,6 +10017,13 @@ export interface TerminalTarget {
   sessionId: string;
   ref: string;
 }
+/**
+ * Cursor belongs to this original target's ordered Data/Resize stream.
+ */
+export interface TerminalOutputRequest {
+  target: TerminalTarget;
+  after?: number;
+}
 export interface TerminalStart {
   sessionId: string;
   /**
diff --git a/platform/host-runtime/src/shell.rs b/platform/host-runtime/src/shell.rs
index 79f02492d5..411df03243 100644
--- a/platform/host-runtime/src/shell.rs
+++ b/platform/host-runtime/src/shell.rs
@@ -29,7 +29,7 @@ mod worker;
 pub use control::Input as ControlInput;
 pub use control::{ControlError, ControlErrorKind, WriteReceipt};
 pub use handle::{ShellHandle, StopReceipt};
-pub use output::{PtyData, PtyReplay, PtyStream, PtyStreamEvent};
+pub use output::{PtyStream, PtyStreamEvent};
 
 use maka_agent_facts::shell_run::ShellRun;
 use maka_agent_facts::terminal::TerminalSize;
diff --git a/platform/host-runtime/src/shell/handle.rs b/platform/host-runtime/src/shell/handle.rs
index 830872ac0a..4212bf0dbb 100644
--- a/platform/host-runtime/src/shell/handle.rs
+++ b/platform/host-runtime/src/shell/handle.rs
@@ -18,7 +18,7 @@
  */
 
 use super::control::{Control, ControlError, Input, WriteReceipt};
-use super::output::{Output, PtyReplay, PtyStream};
+use super::output::{Output, PtyStream};
 use super::{ShellError, Update};
 use maka_agent_facts::shell_run::ShellRun;
 use maka_agent_facts::terminal::TerminalSize;
@@ -72,12 +72,13 @@ impl ShellHandle {
         self.control_gate.clone()
     }
 
-    pub fn replay(&self) -> Option<PtyReplay> {
-        self.pty.as_ref().map(|pty| pty.output.replay())
-    }
-
-    pub fn attach(&self) -> Option<(PtyReplay, PtyStream)> {
-        self.pty.as_ref().map(|pty| pty.output.attach())
+    /// Attach to the original ephemeral byte source; no source means no retained
+    /// raw output, never a fabricated replay of the durable screen projection.
+    pub fn output(&self, after: u64) -> super::Result<Option<PtyStream>> {
+        self.pty
+            .as_ref()
+            .map(|pty| pty.output.stream(after))
+            .transpose()
     }
 
     pub fn latest(&self) -> Update {
diff --git a/platform/host-runtime/src/shell/output.rs b/platform/host-runtime/src/shell/output.rs
index 9f9295a743..530ce5aa91 100644
--- a/platform/host-runtime/src/shell/output.rs
+++ b/platform/host-runtime/src/shell/output.rs
@@ -25,38 +25,61 @@ use std::{
 };
 use tokio::sync::Notify;
 
-const REPLAY_BYTES: usize = 80 * 1024;
-const FRAME_BYTES: usize = 16 * 1024;
-const FRAME_COUNT: usize = 8;
+const DATA_BYTES: usize = 16 * 1024;
+const EVENT_COUNT: usize = 8;
 const MAX_SEQUENCE: u64 = 9_007_199_254_740_991;
 
-/// An atomic, ephemeral terminal cut, not a durable resource revision.
-#[derive(Clone, Debug)]
-pub struct PtyReplay {
-    pub sequence: u64,
-    pub buffer: String,
-    pub size: TerminalSize,
-}
-
-#[derive(Debug)]
-pub struct PtyData {
-    pub sequence: u64,
-    pub data: String,
-}
-
-#[derive(Debug)]
+/// Ephemeral native observation order, independent of durable ShellRun revisions.
+#[derive(Clone, Debug, PartialEq, Eq)]
 pub enum PtyStreamEvent {
-    Data(Arc<PtyData>),
-    /// The cursor fell behind the bounded ring. Replace the prior terminal cut.
-    Reset(PtyReplay),
-    Closed,
+    Data {
+        sequence: u64,
+        size: TerminalSize,
+        bytes: Arc<[u8]>,
+    },
+    Resize {
+        sequence: u64,
+        size: TerminalSize,
+    },
+    /// No cursor advance: resumption after a gap is an explicit caller choice.
+    Gap {
+        after: u64,
+        first_available: u64,
+    },
+    Pending {
+        after: u64,
+        size: TerminalSize,
+    },
+    /// Byte-source closure, not process success or confirmed native cleanup.
+    Closed {
+        after: u64,
+        size: TerminalSize,
+    },
 }
 
 struct State {
-    replay: PtyReplay,
-    frames: VecDeque<Arc<PtyData>>,
+    sequence: u64,
+    size: TerminalSize,
+    // Only consecutive Data/Resize events enter this bounded ring.
+    events: VecDeque<PtyStreamEvent>,
     closed: bool,
 }
+impl State {
+    fn capacity(&self, count: u64) -> Result<()> {
+        if self.closed || count > MAX_SEQUENCE - self.sequence {
+            return Err(ShellError::Rejected(
+                "PTY output sequence exhausted or closed",
+            ));
+        }
+        Ok(())
+    }
+    fn push(&mut self, event: PtyStreamEvent) {
+        if self.events.len() == EVENT_COUNT {
+            self.events.pop_front();
+        }
+        self.events.push_back(event);
+    }
+}
 
 #[derive(Clone)]
 pub(super) struct Output(Arc<Shared>);
@@ -66,22 +89,18 @@ struct Shared {
     host_changes: tokio::sync::watch::Sender<()>,
 }
 
-/// Independent observation cursor. Dropping it never cancels its process.
+/// Independent bounded observation cursor. Dropping it never stops the process.
 pub struct PtyStream {
     output: Output,
-    sequence: u64,
+    after: u64,
 }
-
 impl Output {
     pub fn new(size: TerminalSize, host_changes: tokio::sync::watch::Sender<()>) -> Self {
         Self(Arc::new(Shared {
             state: Mutex::new(State {
-                replay: PtyReplay {
-                    sequence: 0,
-                    buffer: String::new(),
-                    size,
-                },
-                frames: VecDeque::new(),
+                sequence: 0,
+                size,
+                events: VecDeque::new(),
                 closed: false,
             }),
             changed: Notify::new(),
@@ -89,93 +108,113 @@ impl Output {
         }))
     }
 
-    pub fn replay(&self) -> PtyReplay {
-        self.0.state.lock().unwrap().replay.clone()
-    }
-
-    pub fn attach(&self) -> (PtyReplay, PtyStream) {
-        let replay = self.replay();
-        let sequence = replay.sequence;
-        (
-            replay,
-            PtyStream {
-                output: self.clone(),
-                sequence,
-            },
-        )
+    pub fn stream(&self, after: u64) -> Result<PtyStream> {
+        if after > self.0.state.lock().unwrap().sequence {
+            return Err(ShellError::Rejected("PTY output cursor is in the future"));
+        }
+        Ok(PtyStream {
+            output: self.clone(),
+            after,
+        })
     }
 
-    /// One parser cut is published atomically. Sequence increments per emitted
-    /// frame, not per native read or pre-coalescing chunk.
-    pub fn publish(&self, text: &str) -> Result<()> {
-        if text.is_empty() {
+    /// Publish original native bytes before any UTF-8 or screen interpretation.
+    pub fn publish(&self, bytes: &[u8]) -> Result<()> {
+        if bytes.is_empty() {
             return Ok(());
         }
-        let chunks = chunks(text);
+        let count = u64::try_from(bytes.len().div_ceil(DATA_BYTES))
+            .map_err(|_| ShellError::Rejected("PTY output sequence exhausted"))?;
         let mut state = self.0.state.lock().unwrap();
-        if state.closed || state.replay.sequence > MAX_SEQUENCE - chunks.len() as u64 {
-            return Err(ShellError::Rejected(
-                "PTY output sequence exhausted or closed",
-            ));
-        }
-        state.replay.buffer.push_str(text);
-        let excess = state.replay.buffer.len().saturating_sub(REPLAY_BYTES);
-        let cut = state.replay.buffer.ceil_char_boundary(excess);
-        state.replay.buffer.drain(..cut);
-        for data in chunks {
-            state.replay.sequence += 1;
-            let sequence = state.replay.sequence;
-            if state.frames.len() == FRAME_COUNT {
-                state.frames.pop_front();
-            }
-            state.frames.push_back(Arc::new(PtyData {
-                sequence,
-                data: data.into(),
-            }));
+        state.capacity(count)?;
+        for bytes in bytes.chunks(DATA_BYTES) {
+            state.sequence += 1;
+            let event = PtyStreamEvent::Data {
+                sequence: state.sequence,
+                size: state.size,
+                bytes: Arc::from(bytes),
+            };
+            state.push(event);
         }
         drop(state);
-        self.0.changed.notify_waiters();
-        self.0.host_changes.send_replace(());
+        self.notify();
         Ok(())
     }
 
-    pub fn resize(&self, size: TerminalSize) {
-        self.0.state.lock().unwrap().replay.size = size;
+    /// The sole worker checks counter capacity before performing native resize.
+    /// This is a preflight, not a second reservation or resize authority.
+    pub fn check_resize(&self, size: TerminalSize) -> Result<()> {
+        let state = self.0.state.lock().unwrap();
+        state.capacity(u64::from(size != state.size))
+    }
+
+    /// Called immediately after native resize succeeds, before parser/SQL work.
+    pub fn resize(&self, size: TerminalSize) -> Result<()> {
+        let mut state = self.0.state.lock().unwrap();
+        state.capacity(u64::from(size != state.size))?;
+        if size == state.size {
+            return Ok(());
+        }
+        state.size = size;
+        state.sequence += 1;
+        let event = PtyStreamEvent::Resize {
+            sequence: state.sequence,
+            size,
+        };
+        state.push(event);
+        drop(state);
+        self.notify();
+        Ok(())
     }
 
     pub fn close(&self) {
         self.0.state.lock().unwrap().closed = true;
+        self.notify();
+    }
+    fn notify(&self) {
         self.0.changed.notify_waiters();
         self.0.host_changes.send_replace(());
     }
 }
-
 impl PtyStream {
-    pub(crate) fn try_next(&mut self) -> Option<PtyStreamEvent> {
+    /// Immediate observation; only delivered Data/Resize advances this cursor.
+    pub fn read(&mut self) -> PtyStreamEvent {
         let state = self.output.0.state.lock().unwrap();
-        if self.sequence < state.replay.sequence {
-            if let Some(frame) = state
-                .frames
-                .iter()
-                .find(|f| f.sequence == self.sequence + 1)
-            {
-                self.sequence = frame.sequence;
-                return Some(PtyStreamEvent::Data(frame.clone()));
+        if self.after < state.sequence {
+            let first_available = state.sequence - state.events.len() as u64 + 1;
+            if self.after + 1 < first_available {
+                return PtyStreamEvent::Gap {
+                    after: self.after,
+                    first_available,
+                };
+            }
+            let index = (self.after + 1 - first_available) as usize;
+            let event = state.events[index].clone();
+            self.after += 1;
+            return event;
+        }
+        if state.closed {
+            PtyStreamEvent::Closed {
+                after: self.after,
+                size: state.size,
+            }
+        } else {
+            PtyStreamEvent::Pending {
+                after: self.after,
+                size: state.size,
             }
-            self.sequence = state.replay.sequence;
-            return Some(PtyStreamEvent::Reset(state.replay.clone()));
         }
-        state.closed.then_some(PtyStreamEvent::Closed)
     }
 
     pub async fn next(&mut self) -> PtyStreamEvent {
         let output = self.output.clone();
         loop {
-            // Register before inspecting the cut: no lost wakeup at attach/EOF.
+            // Register before inspection, so publication and EOF cannot be lost.
             let changed = output.0.changed.notified();
             tokio::pin!(changed);
             changed.as_mut().enable();
-            if let Some(event) = self.try_next() {
+            let event = self.read();
+            if !matches!(event, PtyStreamEvent::Pending { .. }) {
                 return event;
             }
             changed.await;
@@ -183,73 +222,143 @@ impl PtyStream {
     }
 }
 
-/// Bound JSON-escaped data, not merely UTF-8: C0 output can expand sixfold.
-/// Frame metadata has its own protocol budget. Eight frames retain <=128 KiB.
-fn chunks(text: &str) -> Vec<&str> {
-    let mut chunks = Vec::new();
-    let (mut start, mut bytes) = (0, 0);
-    for (offset, ch) in text.char_indices() {
-        let cost = match ch {
-            '"' | '\\' | '\n' | '\r' | '\t' | '\u{08}' | '\u{0c}' => 2,
-            '\u{00}'..='\u{1f}' => 6,
-            _ => ch.len_utf8(),
-        };
-        if bytes + cost > FRAME_BYTES {
-            chunks.push(&text[start..offset]);
-            start = offset;
-            bytes = 0;
-        }
-        bytes += cost;
-    }
-    if start != text.len() {
-        chunks.push(&text[start..]);
-    }
-    chunks
-}
-
 #[cfg(test)]
 mod tests {
     use super::*;
-
-    #[tokio::test]
-    async fn attachment_cuts_lag_and_eof_preserve_unicode_and_bounded_json_frames() {
-        let output = Output::new(
+    fn output() -> Output {
+        Output::new(
             TerminalSize::new(80, 24).unwrap(),
             tokio::sync::watch::channel(()).0,
+        )
+    }
+
+    #[test]
+    fn ordered_bytes_and_resize_keep_exact_cuts_and_retained_closed_data() {
+        let output = output();
+        let initial = TerminalSize::new(80, 24).unwrap();
+        let resized = TerminalSize::new(100, 40).unwrap();
+        let mut stream = output.stream(0).unwrap();
+        assert_eq!(
+            stream.read(),
+            PtyStreamEvent::Pending {
+                after: 0,
+                size: initial
+            }
+        );
+        output.publish(b"\xff\0\x1b[31m").unwrap();
+        output.check_resize(resized).unwrap();
+        output.resize(resized).unwrap();
+        output.resize(resized).unwrap(); // same-size acceptance is not a new cut
+        output.publish(&[0xe4, 0xb8, 0xad]).unwrap();
+        output.close();
+        assert_eq!(
+            stream.read(),
+            PtyStreamEvent::Data {
+                sequence: 1,
+                size: initial,
+                bytes: Arc::from(&b"\xff\0\x1b[31m"[..])
+            }
+        );
+        assert_eq!(
+            stream.read(),
+            PtyStreamEvent::Resize {
+                sequence: 2,
+                size: resized
+            }
         );
-        output.publish("before中").unwrap();
-        let (cut, mut stream) = output.attach();
-        assert_eq!(cut.buffer, "before中");
-        let data = "\0中😀\x1b\\\"".repeat(1800);
-        output.publish(&data).unwrap();
-        let last = output.replay().sequence;
-        let mut actual = String::new();
-        for sequence in cut.sequence + 1..=last {
-            let PtyStreamEvent::Data(frame) = stream.next().await else {
-                panic!("unexpected gap")
+        assert_eq!(
+            stream.read(),
+            PtyStreamEvent::Data {
+                sequence: 3,
+                size: resized,
+                bytes: Arc::from(&[0xe4, 0xb8, 0xad][..])
+            }
+        );
+        assert_eq!(
+            stream.read(),
+            PtyStreamEvent::Closed {
+                after: 3,
+                size: resized
+            }
+        );
+        assert_eq!(
+            stream.read(),
+            PtyStreamEvent::Closed {
+                after: 3,
+                size: resized
+            }
+        );
+        assert!(output.stream(4).is_err());
+    }
+
+    #[test]
+    fn bounded_byte_events_gap_without_advancing_and_explicit_resumption() {
+        let output = output();
+        let mut lagged = output.stream(0).unwrap();
+        let bytes: Vec<u8> = (0..DATA_BYTES * 9 + 7).map(|index| index as u8).collect();
+        output.publish(&bytes).unwrap();
+        let gap = PtyStreamEvent::Gap {
+            after: 0,
+            first_available: 3,
+        };
+        assert_eq!(lagged.read(), gap);
+        output.close();
+        assert_eq!(
+            lagged.read(),
+            gap,
+            "closure must not silently skip an omitted event"
+        );
+        let mut resumed = output.stream(2).unwrap();
+        let mut actual = Vec::new();
+        for expected in 3..=10 {
+            let PtyStreamEvent::Data {
+                sequence, bytes, ..
+            } = resumed.read()
+            else {
+                panic!("expected retained bytes")
             };
-            assert_eq!(frame.sequence, sequence);
-            assert!(serde_json::to_vec(&frame.data).unwrap().len() <= FRAME_BYTES + 2);
-            actual.push_str(&frame.data);
+            assert_eq!(sequence, expected);
+            assert!(bytes.len() <= DATA_BYTES);
+            actual.extend_from_slice(&bytes);
         }
-        assert_eq!(actual, data);
-        let (_, mut lagged) = output.attach();
-        for _ in 0..20 {
-            output.publish(&"中".repeat(4000)).unwrap();
+        assert_eq!(actual, bytes[DATA_BYTES * 2..]);
+        assert!(matches!(
+            resumed.read(),
+            PtyStreamEvent::Closed { after: 10, .. }
+        ));
+    }
+
+    #[tokio::test]
+    async fn resize_only_wakes_a_waiter_and_closure_wakes_without_cursor_advance() {
+        let output = output();
+        let size = TerminalSize::new(100, 40).unwrap();
+        let mut stream = output.stream(0).unwrap();
+        {
+            let next = stream.next();
+            tokio::pin!(next);
+            assert!(futures_util::poll!(next.as_mut()).is_pending());
+            output.resize(size).unwrap();
+            assert_eq!(next.await, PtyStreamEvent::Resize { sequence: 1, size });
         }
-        let PtyStreamEvent::Reset(cut) = lagged.next().await else {
-            panic!("missing reset")
-        };
-        assert!(cut.buffer.len() <= REPLAY_BYTES);
-        assert!(cut.buffer.chars().all(|ch| ch == '中'));
-        let pending = lagged.next();
-        tokio::pin!(pending);
+        {
+            let next = stream.next();
+            tokio::pin!(next);
+            assert!(futures_util::poll!(next.as_mut()).is_pending());
+            output.close();
+            assert_eq!(next.await, PtyStreamEvent::Closed { after: 1, size });
+        }
+    }
+
+    #[test]
+    fn sequence_capacity_rejects_before_resize_and_never_wraps() {
+        let output = output();
+        output.0.state.lock().unwrap().sequence = MAX_SEQUENCE;
         assert!(
-            tokio::time::timeout(std::time::Duration::from_millis(1), &mut pending)
-                .await
+            output
+                .check_resize(TerminalSize::new(81, 24).unwrap())
                 .is_err()
         );
-        output.close();
-        assert!(matches!(pending.await, PtyStreamEvent::Closed));
+        assert!(output.publish(b"x").is_err());
+        assert!(output.stream(MAX_SEQUENCE + 1).is_err());
     }
 }
diff --git a/platform/host-runtime/src/shell/terminal.rs b/platform/host-runtime/src/shell/terminal.rs
index b1fa2d5215..5e96f41409 100644
--- a/platform/host-runtime/src/shell/terminal.rs
+++ b/platform/host-runtime/src/shell/terminal.rs
@@ -50,6 +50,9 @@ impl Terminal {
     }
 
     pub fn output(&mut self, bytes: &[u8], eof: bool) -> Result<()> {
+        // Native bytes remain observable even when decoding replaces invalid
+        // text or the parser has failed and final native output is draining.
+        self.output.publish(bytes)?;
         let text = self.decoder.decode(bytes, eof);
         let Some(screen) = &mut self.screen else {
             return Ok(());
@@ -57,9 +60,6 @@ impl Terminal {
         match screen.write(&text) {
             Ok(replies) => {
                 self.snapshot = screen.snapshot()?;
-                // Observational stream, not a durable outcome. Publish only a
-                // completed parser cut, including final output during drain.
-                self.output.publish(&text)?;
                 if !replies.is_empty() {
                     let pending: usize = self
                         .writes
@@ -94,7 +94,6 @@ impl Terminal {
             .ok_or(ShellError::Rejected("terminal parser unavailable"))?;
         screen.resize(size)?;
         self.snapshot = screen.snapshot()?;
-        self.output.resize(size);
         Ok(())
     }
 
@@ -233,6 +232,59 @@ pub(super) async fn drain(
 mod tests {
     use super::Decoder;
 
+    #[test]
+    fn raw_output_preserves_native_bytes_before_screen_decoding() {
+        use super::{Output, Terminal};
+        use maka_agent_facts::terminal::TerminalSize;
+
+        let size = TerminalSize::new(80, 24).unwrap();
+        let output = Output::new(size, tokio::sync::watch::channel(()).0);
+        let mut terminal = Terminal::new(size, output.clone());
+        // Exercise the actual Host decoder/parser/ring path. These supplied
+        // native-byte cuts are not a claim of OS PTY transport verification.
+        terminal.output(b"\xff\0\x1b[31m\xe4", false).unwrap();
+        terminal.output(b"\xb8\xad\x1b[0m", false).unwrap();
+        terminal.output(b"\xe2\x82", true).unwrap();
+        let mut stream = output.stream(0).unwrap();
+        let mut raw = Vec::new();
+        while let super::super::output::PtyStreamEvent::Data { bytes, .. } = stream.read() {
+            raw.extend_from_slice(&bytes);
+        }
+        assert_eq!(
+            raw.as_slice(),
+            b"\xff\0\x1b[31m\xe4\xb8\xad\x1b[0m\xe2\x82",
+            "the raw replay must preserve invalid bytes, NUL, ANSI and incomplete EOF independently of the screen decoder"
+        );
+    }
+
+    #[test]
+    fn parser_failure_and_later_drain_preserve_original_raw_bytes() {
+        use super::super::output::PtyStreamEvent;
+        use super::{Output, Terminal};
+        use maka_agent_facts::terminal::TerminalSize;
+
+        let size = TerminalSize::new(80, 24).unwrap();
+        let output = Output::new(size, tokio::sync::watch::channel(()).0);
+        let mut terminal = Terminal::new(size, output.clone());
+        terminal.output(b"\x1b]0;", false).unwrap();
+        let chunk = vec![b'x'; 16 * 1024];
+        for _ in 0..3 {
+            terminal.output(&chunk, false).unwrap();
+        }
+        assert!(terminal.output(&chunk, false).is_err());
+        assert!(terminal.screen.is_none());
+        terminal.output(b"final-drain\xff", true).unwrap();
+        let mut expected = b"\x1b]0;".to_vec();
+        expected.extend_from_slice(&chunk.repeat(4));
+        expected.extend_from_slice(b"final-drain\xff");
+        let mut stream = output.stream(0).unwrap();
+        let mut raw = Vec::new();
+        while let PtyStreamEvent::Data { bytes, .. } = stream.read() {
+            raw.extend_from_slice(&bytes);
+        }
+        assert_eq!(raw, expected);
+    }
+
     #[test]
     fn every_utf8_cut_matches_lossy_eof_without_losing_terminal_controls() {
         let bytes = b"\x1b[31m\xe4\xb8\xad\xf0\x9f\x98\x80\xff\xe2\x82";
diff --git a/platform/host-runtime/src/shell/worker/control.rs b/platform/host-runtime/src/shell/worker/control.rs
index b54c304e31..98a27f2791 100644
--- a/platform/host-runtime/src/shell/worker/control.rs
+++ b/platform/host-runtime/src/shell/worker/control.rs
@@ -70,12 +70,16 @@ impl Worker {
         if let Some(size) = control.size {
             let result = async {
                 if size != terminal.snapshot.size {
+                    // This sole worker owns publication: preflight the bounded
+                    // event counter before accepting an irreversible OS resize.
+                    self.output.check_resize(size)?;
                     child.resize(size).await?;
+                    resized = true;
                     resize_changed = true;
-                }
-                resized = true;
-                if resize_changed {
+                    self.output.resize(size)?;
                     terminal.resize(size)?;
+                } else {
+                    resized = true;
                 }
                 self.persist(terminal, None).await
             }
diff --git a/plugins/agent/native/src/owner/tests/terminal.rs b/plugins/agent/native/src/owner/tests/terminal.rs
index 57f5c56fc6..3737d80691 100644
--- a/plugins/agent/native/src/owner/tests/terminal.rs
+++ b/plugins/agent/native/src/owner/tests/terminal.rs
@@ -184,6 +184,8 @@ async fn terminal_original_receipts_scope_isolation_boundary_revoke_and_read_sto
         assert_eq!(started["kind"], "completed", "{started}");
         let replayed = mutation(&f, &owner, "start", input, operation).await;
         assert_eq!(replayed, started);
+        let pipe_output = query(&f, "readOutput", json!({"target":{"sessionId":session,"ref":started["value"]["result"]["ref"]}})).await;
+        assert_eq!(pipe_output["kind"], "failed", "pipes must not masquerade as PTY raw output: {pipe_output}");
         wait_file(&f, "once.txt", "once").await;
         files::change(&f, &session, "danger_full_access", false, true).await;
         let target = start(&f, &owner, &session).await;
@@ -194,6 +196,8 @@ async fn terminal_original_receipts_scope_isolation_boundary_revoke_and_read_sto
         let other = f.create("Other Session").await;
         let denied = query(&f, "read", json!({"sessionId":other,"ref":target["ref"]})).await;
         assert_eq!(denied["kind"], "failed");
+        let denied_output = query(&f, "readOutput", json!({"target":{"sessionId":other,"ref":target["ref"]}})).await;
+        assert_eq!(denied_output["kind"], "failed", "raw output must keep Session authority: {denied_output}");
         let denied = query(&f, "list", json!({"sessionId":"foreign-session"})).await;
         assert_eq!(denied["kind"], "failed");
         let mut no_caller = f.app.client().subscribe(binding(&f).await,"acquire".into(),target.clone(),scope(&f.issuer,true),Attachments::new().into()).await.unwrap();
@@ -211,6 +215,8 @@ async fn terminal_original_receipts_scope_isolation_boundary_revoke_and_read_sto
         assert!(listed["value"]["resources"].as_array().unwrap().iter().all(|r| r["result"]["output"].is_null()));
         let foreign = query(&f, "read", json!({"sessionId":session,"ref":"maka://runtime/background-tasks/other-provider-resource"})).await;
         assert_eq!(foreign["kind"], "failed");
+        let foreign_output = query(&f, "readOutput", json!({"target":{"sessionId":session,"ref":"maka://runtime/background-tasks/other-provider-resource"}})).await;
+        assert_eq!(foreign_output["kind"], "failed", "raw output must keep provider authority: {foreign_output}");
         let (lease, controller) = acquire(&f, &owner, &target).await;
         files::change(&f, &session, "read_only", true, true).await;
         let denied = mutation(&f,&owner,"control",command(&controller,1,"printf revoked > revoked.txt"),Uuid::new_v4()).await;
@@ -285,3 +291,207 @@ async fn workhub_terminal_selects_coordinator_namespace_and_rejects_original_age
         processes::close(&f).await;
     }).await.unwrap();
 }
+
+fn output_request(target: &Value, after: u64) -> Value {
+    json!({"target":target,"after":after})
+}
+fn output_event(value: Value) -> Value {
+    assert_eq!(value["kind"], "completed", "{value}");
+    assert!(serde_json::to_vec(&value).unwrap().len() <= 48 * 1024);
+    value["value"].clone()
+}
+async fn output_read(f: &Fixture, target: &Value, after: u64) -> Value {
+    output_event(query(f, "readOutput", output_request(target, after)).await)
+}
+fn output_data(event: &Value, after: &mut u64, size: &Value) -> Vec<u8> {
+    use base64::Engine;
+    assert_eq!(event["kind"], "data", "{event}");
+    let sequence = event["sequence"].as_u64().unwrap();
+    assert_eq!(
+        sequence,
+        *after + 1,
+        "raw events must advance in original order: {event}"
+    );
+    assert_eq!(&event["size"], size);
+    *after = sequence;
+    let bytes = base64::engine::general_purpose::STANDARD
+        .decode(event["dataBase64"].as_str().unwrap())
+        .unwrap();
+    assert!(!bytes.is_empty() && bytes.len() <= 16 * 1024);
+    bytes
+}
+async fn output_through(
+    watch: &mut Subscription,
+    after: &mut u64,
+    bytes: &mut Vec<u8>,
+    needle: &[u8],
+    size: &Value,
+) {
+    while !bytes.windows(needle.len()).any(|window| window == needle) {
+        let event = output_event(
+            watch
+                .recv()
+                .await
+                .expect("raw output ended before its ACK fence"),
+        );
+        match event["kind"].as_str().unwrap() {
+            "pending" => assert_eq!(event["after"], *after),
+            "data" => bytes.extend(output_data(&event, after, size)),
+            _ => panic!("unexpected event before raw byte fence: {event}"),
+        }
+    }
+}
+
+#[tokio::test]
+async fn terminal_raw_bytes_resize_resume_gap_and_observer_cancellation_preserve_native_owner() {
+    tokio::time::timeout(Duration::from_secs(90), async {
+        let (_, f, session) = processes::fixture().await;
+        files::change(&f, &session, "danger_full_access", false, true).await;
+        let owner = caller();
+        let target = start(&f, &owner, &session).await;
+        let (lease, controller) = acquire(&f, &owner, &target).await;
+        let denied = f.app.client().start(binding(&f).await, "readOutput".into(), output_request(&target, 0), scope(&f.issuer, false), Attachments::new().into()).await;
+        assert_eq!(denied.err().unwrap(), "denied", "raw queries require the original application grant");
+        let denied = f.app.client().subscribe(binding(&f).await, "watchOutput".into(), output_request(&target, 0), scope(&f.issuer, false), Attachments::new().into()).await;
+        assert_eq!(denied.err().unwrap(), "denied", "raw subscriptions require the original application grant");
+        let original_size = json!({"cols":80,"rows":24});
+        let resized = json!({"cols":100,"rows":40});
+
+        // Only the pre-script interactive prompt is intentionally discarded.
+        // A startup Gap is explicitly resumed before any promised payload exists.
+        let mut after = 0;
+        loop {
+            let event = output_read(&f, &target, after).await;
+            match event["kind"].as_str().unwrap() {
+                "data" | "resize" => after = event["sequence"].as_u64().unwrap(),
+                "gap" => after = event["firstAvailable"].as_u64().unwrap() - 1,
+                "pending" => break,
+                _ => panic!("interactive PTY unexpectedly unavailable: {event}"),
+            }
+        }
+        let mut watch = subscribe(&f, &owner, "watchOutput", output_request(&target, after)).await;
+        // The marker is absent from the entered command, so shell echo/prompt
+        // cannot satisfy this fence. Raw discipline removes echo and ONLCR.
+        std::fs::write(f.workspace.join("raw-fixture.sh"), r#"stty raw -echo
+printf '\036RAW_BEGIN\037\377\000\033[31m\344'
+dd bs=1 count=1 of=/dev/null 2>/dev/null
+printf '\270\255\033[0m'
+stty size
+printf '\036SECOND\037'
+dd bs=1 count=1 of=/dev/null 2>/dev/null
+printf '\342\202'
+"#).unwrap();
+        let launched = mutation(&f, &owner, "control", command(&controller, 1, "exec /bin/sh ./raw-fixture.sh"), Uuid::new_v4()).await;
+        assert_eq!(launched["kind"], "completed", "{launched}");
+        let marker = b"\x1eRAW_BEGIN\x1f";
+        let prefix = b"\xff\0\x1b[31m\xe4";
+        let mut head = marker.to_vec();
+        head.extend_from_slice(prefix);
+        let mut initial = Vec::new();
+        output_through(&mut watch, &mut after, &mut initial, &head, &original_size).await;
+        let at = initial.windows(marker.len()).position(|part| part == marker).unwrap() + marker.len();
+        let mut actual = initial[at..].to_vec();
+        assert_eq!(actual, prefix, "the first split UTF8 byte must already be observable");
+        let before_resize = after;
+        let receipt = mutation(&f, &owner, "control", json!({"controller":controller,"sequence":2,"size":resized}), Uuid::new_v4()).await;
+        assert_eq!(receipt["kind"], "completed", "{receipt}");
+        assert_eq!(receipt["value"]["acceptedBytes"], 0);
+        assert_eq!(receipt["value"]["resizeChanged"], true);
+        let resize = loop {
+            let event = output_event(watch.recv().await.unwrap());
+            if event["kind"] == "pending" { continue; }
+            break event;
+        };
+        assert_eq!(resize["kind"], "resize", "{resize}");
+        assert_eq!(resize["size"], resized);
+        assert_eq!(resize["sequence"], after + 1);
+        assert_eq!(output_read(&f, &target, before_resize).await, resize);
+        after = resize["sequence"].as_u64().unwrap();
+        let future = query(&f, "readOutput", output_request(&target, 9_007_199_254_740_991)).await;
+        assert_eq!(future["kind"], "failed", "known live streams must reject a future sequence: {future}");
+        let ack = mutation(&f, &owner, "control", json!({"controller":controller,"sequence":3,"actions":[{"type":"text","text":"x"}]}), Uuid::new_v4()).await;
+        assert_eq!(ack["kind"], "completed", "{ack}");
+        let middle = b"\xb8\xad\x1b[0m40 100\n\x1eSECOND\x1f";
+        let mut following = Vec::new();
+        output_through(&mut watch, &mut after, &mut following, middle, &resized).await;
+        assert_eq!(following, middle, "stty must witness the actual native resize");
+        actual.extend_from_slice(&following);
+        watch.cancel();
+        watch.completion().await.cleanup.unwrap();
+        assert_eq!(query(&f, "read", target.clone()).await["value"]["result"]["status"], "running");
+
+        // Resize receipts, not timed writes, force nine distinct ordered events
+        // while the real script remains gated on its second ACK.
+        let before_gap = after;
+        for index in 0..9u64 {
+            let receipt = mutation(&f, &owner, "control", json!({"controller":controller,"sequence":4+index,"size":{"cols":101+index,"rows":40}}), Uuid::new_v4()).await;
+            assert_eq!(receipt["kind"], "completed", "observer cancellation must leave its independent controller usable: {receipt}");
+            assert_eq!(receipt["value"]["resizeChanged"], true);
+        }
+        let gap = output_read(&f, &target, before_gap).await;
+        assert_eq!(gap["kind"], "gap", "{gap}");
+        assert_eq!(gap["after"], before_gap);
+        assert!(gap["firstAvailable"].as_u64().unwrap() > before_gap + 1);
+        let mut lost = subscribe(&f, &owner, "watchOutput", output_request(&target, before_gap)).await;
+        assert_eq!(output_event(lost.recv().await.unwrap()), gap);
+        assert!(lost.recv().await.is_none(), "a Gap must finish this watch");
+        let completed = lost.completion().await;
+        completed.cleanup.unwrap();
+        assert_eq!(output_event(completed.result.unwrap()), gap);
+        after = gap["firstAvailable"].as_u64().unwrap() - 1;
+        for col in 102..=109 {
+            let event = output_read(&f, &target, after).await;
+            assert_eq!(event["kind"], "resize", "{event}");
+            assert_eq!(event["sequence"], after + 1);
+            assert_eq!(event["size"], json!({"cols":col,"rows":40}));
+            after += 1;
+        }
+        let final_size = json!({"cols":109,"rows":40});
+        assert_eq!(output_read(&f, &target, after).await, json!({"kind":"pending","after":after,"size":final_size}));
+        let mut resumed = subscribe(&f, &owner, "watchOutput", output_request(&target, after)).await;
+        assert_eq!(output_event(resumed.recv().await.unwrap()), json!({"kind":"pending","after":after,"size":final_size}), "the final observer must be attached before releasing the child");
+        let ack = mutation(&f, &owner, "control", json!({"controller":controller,"sequence":13,"actions":[{"type":"text","text":"y"}]}), Uuid::new_v4()).await;
+        assert_eq!(ack["kind"], "completed", "{ack}");
+        loop {
+            let event = output_event(resumed.recv().await.unwrap());
+            match event["kind"].as_str().unwrap() {
+                "pending" => assert_eq!(event["after"], after),
+                "data" => actual.extend(output_data(&event, &mut after, &final_size)),
+                "closed" => {
+                    assert_eq!(event["after"], after);
+                    assert_eq!(event["size"], final_size);
+                    break;
+                },
+                _ => panic!("unexpected resumed event: {event}"),
+            }
+        }
+        resumed.completion().await.cleanup.unwrap();
+        let mut expected = prefix.to_vec();
+        expected.extend_from_slice(middle);
+        expected.extend_from_slice(b"\xe2\x82");
+        assert_eq!(actual, expected, "raw bytes must retain ff, NUL, ANSI and both split and incomplete UTF8");
+        let mut screen = subscribe(&f, &owner, "watch", target.clone()).await;
+        loop {
+            let snapshot = screen.recv().await.unwrap();
+            let status = snapshot["value"]["result"]["status"].as_str().unwrap();
+            if !matches!(status, "starting" | "running") {
+                assert_eq!(status, "completed", "{snapshot}");
+                break;
+            }
+        }
+        screen.cancel();
+        screen.completion().await.cleanup.unwrap();
+        // Existing stop waits for the real worker's drained fence; it cannot
+        // signal this already completed script or turn raw EOF into exit proof.
+        let stopped = mutation(&f, &owner, "stop", target.clone(), Uuid::new_v4()).await;
+        assert_eq!(stopped["kind"], "completed", "{stopped}");
+        assert_eq!(stopped["value"]["applied"], false);
+        lease.completion().await.cleanup.unwrap();
+        assert_eq!(output_read(&f, &target, after).await, json!({"kind":"unavailable","reason":"not_retained"}));
+        let historical = query(&f, "read", target).await;
+        assert_eq!(historical["kind"], "completed");
+        assert_eq!(historical["value"]["result"]["status"], "completed");
+        assert!(historical["value"]["result"]["output"]["screen"].as_str().unwrap().contains("40 100"));
+        processes::close(&f).await;
+    }).await.unwrap();
+}
diff --git a/plugins/workspace/api/README.md b/plugins/workspace/api/README.md
index d8368fb814..c1a2ef9803 100644
--- a/plugins/workspace/api/README.md
+++ b/plugins/workspace/api/README.md
@@ -23,4 +23,6 @@
 
 Defines the shared contracts for workspace files, processes, terminals, protocol processes and Jev. Native providers and authoring tools use the same declarations. Request paths and workspace identifiers describe a selection; the provider still checks the actual bound directory and call authority.
 
+The Terminal contract also exposes `readOutput` and `watchOutput` for ordered raw PTY bytes (Base64) and resize events. Each read returns one event from a ring of at most eight events, with up to 16 KiB of bytes per data event. Keep each `after` cursor with its original target. `Gap` ends the watch and reports the first retained event; resume explicitly from `firstAvailable - 1`. A retained suffix cannot restore complete terminal state. Raw output is ephemeral: a new attachment after exi [...]
+
 [Source](src/lib.rs) · [Cargo manifest](Cargo.toml)
diff --git a/plugins/workspace/api/README.zh-CN.md b/plugins/workspace/api/README.zh-CN.md
index de77218e28..e23ea15de4 100644
--- a/plugins/workspace/api/README.zh-CN.md
+++ b/plugins/workspace/api/README.zh-CN.md
@@ -23,4 +23,6 @@
 
 定义工作区文件、进程、终端、协议进程和 Jev 的共用契约。原生提供方与开发工具使用相同声明。请求路径和工作区标识用于描述选择;提供方仍需检查实际绑定目录和调用权限。
 
+Terminal 契约还提供 `readOutput` 和 `watchOutput`,按顺序读取原始 PTY 字节(Base64)及尺寸变化事件。每次读取返回一个事件;缓冲最多保留八个事件,每个数据事件最多包含 16 KiB 字节。`after` 游标必须与原始目标一起保留。`Gap` 会结束订阅并报告最早保留的事件;如需继续,应显式从 `firstAvailable - 1` 恢复。保留的尾段不能还原完整终端状态。原始输出是临时数据:退出或重启后新接入会报告 `Unavailable`,屏幕及历史读取仍可使用。`Closed` 表示输出结束,不代表进程清理已确认。输出观察需要显式方法权限,不需要控制器。
+
 [源码](src/lib.rs) · [Cargo 配置](Cargo.toml)
diff --git a/plugins/workspace/api/src/terminal.rs b/plugins/workspace/api/src/terminal.rs
index 32b76d0fe5..b0e1a45ed0 100644
--- a/plugins/workspace/api/src/terminal.rs
+++ b/plugins/workspace/api/src/terminal.rs
@@ -46,6 +46,64 @@ pub struct Target {
     #[serde(rename = "ref")]
     pub reference: String,
 }
+/// Cursor belongs to this original target's ordered Data/Resize stream.
+#[derive(Deserialize, JsonSchema)]
+#[serde(rename_all = "camelCase", deny_unknown_fields)]
+#[schemars(rename = "TerminalOutputRequest")]
+pub struct OutputRequest {
+    pub target: Target,
+    #[serde(default)]
+    #[schemars(range(max = 9007199254740991u64))]
+    pub after: u64,
+}
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, JsonSchema)]
+#[serde(rename_all = "snake_case")]
+#[schemars(rename = "TerminalOutputUnavailable")]
+pub enum OutputUnavailable {
+    NotRetained,
+}
+/// Ephemeral output observation; Closed is not a process cleanup receipt.
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, JsonSchema)]
+#[serde(
+    tag = "kind",
+    rename_all = "snake_case",
+    rename_all_fields = "camelCase",
+    deny_unknown_fields
+)]
+#[schemars(rename = "TerminalOutputEvent")]
+pub enum OutputEvent {
+    Data {
+        #[schemars(range(min = 1, max = 9007199254740991u64))]
+        sequence: u64,
+        size: TerminalSize,
+        #[schemars(length(min = 1, max = 21848))]
+        data_base64: String,
+    },
+    Resize {
+        #[schemars(range(min = 1, max = 9007199254740991u64))]
+        sequence: u64,
+        size: TerminalSize,
+    },
+    Gap {
+        #[schemars(range(max = 9007199254740991u64))]
+        after: u64,
+        #[schemars(range(min = 1, max = 9007199254740991u64))]
+        first_available: u64,
+    },
+    Pending {
+        #[schemars(range(max = 9007199254740991u64))]
+        after: u64,
+        size: TerminalSize,
+    },
+    Closed {
+        #[schemars(range(max = 9007199254740991u64))]
+        after: u64,
+        size: TerminalSize,
+    },
+    Unavailable {
+        reason: OutputUnavailable,
+    },
+}
 #[derive(Deserialize, JsonSchema)]
 #[serde(rename_all = "camelCase", deny_unknown_fields)]
 #[schemars(rename = "TerminalStart")]
@@ -152,6 +210,14 @@ pub fn contract() -> Contract {
                     "watch".into(),
                     method::<Target, ResourceUpdate>(MethodMode::Subscription),
                 ),
+                (
+                    "readOutput".into(),
+                    method::<OutputRequest, OutputEvent>(MethodMode::Query),
+                ),
+                (
+                    "watchOutput".into(),
+                    method::<OutputRequest, OutputEvent>(MethodMode::Subscription),
+                ),
                 (
                     "start".into(),
                     method::<Start, ResourceUpdate>(MethodMode::Mutation),
diff --git a/plugins/workspace/workspace-processes/native/Cargo.toml b/plugins/workspace/workspace-processes/native/Cargo.toml
index 5f7d0af9e7..aa30dc9b01 100644
--- a/plugins/workspace/workspace-processes/native/Cargo.toml
+++ b/plugins/workspace/workspace-processes/native/Cargo.toml
@@ -23,6 +23,7 @@ license.workspace = true
 publish.workspace = true
 
 [dependencies]
+base64 = "0.22.1"
 maka-agent-facts.workspace = true
 maka-capabilities.workspace = true
 futures-util.workspace = true
diff --git a/plugins/workspace/workspace-processes/native/README.md b/plugins/workspace/workspace-processes/native/README.md
index e274a078c3..b5843ad2eb 100644
--- a/plugins/workspace/workspace-processes/native/README.md
+++ b/plugins/workspace/workspace-processes/native/README.md
@@ -25,4 +25,6 @@ Provides workspace command, terminal and protocol-process services. `protocol` h
 
 `health` (the default `BackgroundTaskHealth` model tool) reads the original session-owned process record. Logs require `include_logs: true`. An optional HTTP(S) URL is probed by the bound Web service under separate network permission; the process result and endpoint observation are independent. A responding endpoint proves neither listener ownership nor browser readiness. Health calls and their nested probes retain original operation receipts.
 
+The Terminal [raw output contract](../../api/README.md) uses the original PTY owner and its bounded in-memory ring. Closing an output observer leaves the process and controller unchanged; existing sequenced control and mutation receipts retain their original authority.
+
 [Source](src/lib.rs) · [Cargo manifest](Cargo.toml)
diff --git a/plugins/workspace/workspace-processes/native/README.zh-CN.md b/plugins/workspace/workspace-processes/native/README.zh-CN.md
index ef11000a95..2b53c0476c 100644
--- a/plugins/workspace/workspace-processes/native/README.zh-CN.md
+++ b/plugins/workspace/workspace-processes/native/README.zh-CN.md
@@ -25,4 +25,6 @@
 
 `health`(默认模型工具 `BackgroundTaskHealth`)读取当前会话原有的进程记录,只有 `include_logs: true` 才返回日志。可选 HTTP(S) 地址由绑定的 Web 服务在独立网络授权下探测;进程状态与端点观测互相独立,端点响应不能证明监听进程归属或浏览器就绪。健康检查及嵌套探测均保留原始操作回执。
 
+Terminal [原始输出契约](../../api/README.zh-CN.md)复用原有 PTY owner 及其有界内存缓冲。关闭输出观察不会改变进程或控制器,现有顺序控制和 mutation 回执仍保留原有权威。
+
 [源码](src/lib.rs) · [Cargo 配置](Cargo.toml)
diff --git a/plugins/workspace/workspace-processes/native/src/terminal.rs b/plugins/workspace/workspace-processes/native/src/terminal.rs
index 6d1ca41ee2..c3e14acb0d 100644
--- a/plugins/workspace/workspace-processes/native/src/terminal.rs
+++ b/plugins/workspace/workspace-processes/native/src/terminal.rs
@@ -20,6 +20,7 @@
 //! Explicit user terminal operations on the original Processes resource owner.
 mod controller;
 mod launch;
+mod output;
 use super::{Processes, SessionConfiguration, cancelled, failed, persistence, worker_error};
 pub(super) use controller::Lease;
 use futures_util::future::BoxFuture;
@@ -94,6 +95,10 @@ impl Processes {
                 self.terminal_read(decode(value)?, name == "watch", context)
                     .await
             }
+            "readOutput" | "watchOutput" => {
+                self.terminal_output(decode(value)?, name == "watchOutput", context)
+                    .await
+            }
             "start" => self
                 .terminal_start(decode(value)?, context)
                 .await
diff --git a/plugins/workspace/workspace-processes/native/src/terminal/output.rs b/plugins/workspace/workspace-processes/native/src/terminal/output.rs
new file mode 100644
index 0000000000..562eb11ee9
--- /dev/null
+++ b/plugins/workspace/workspace-processes/native/src/terminal/output.rs
@@ -0,0 +1,106 @@
+/*
+ * 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 base64::{Engine, engine::general_purpose::STANDARD};
+use maka_host_runtime::shell::PtyStreamEvent;
+
+impl Processes {
+    pub(super) async fn terminal_output(
+        &self,
+        input: OutputRequest,
+        watch: bool,
+        context: &Context,
+    ) -> Result<Value, ToolError> {
+        if input.after > MAX_SEQUENCE {
+            return Err(failed("Invalid terminal output cursor"));
+        }
+        let record = self.terminal_record(&input.target).await?;
+        if !record.output.is_pty() {
+            return Err(failed("Only a PTY has raw terminal output"));
+        }
+        let mut stream = match self.resources.get(&input.target.session_id, &record.id) {
+            Some(handle) => handle.output(input.after).map_err(failed)?,
+            None => None,
+        };
+        let mut event = match &mut stream {
+            Some(stream) => project(stream.read()),
+            None => OutputEvent::Unavailable {
+                reason: OutputUnavailable::NotRetained,
+            },
+        };
+        let mut last = None;
+        loop {
+            let terminal = matches!(
+                event,
+                OutputEvent::Gap { .. }
+                    | OutputEvent::Closed { .. }
+                    | OutputEvent::Unavailable { .. }
+            );
+            let value = completed(event)?;
+            maka_plugin_core::json::check_delivery(&value, 48 * 1024).map_err(failed)?;
+            if !watch {
+                return Ok(value);
+            }
+            // The retained output Arc conveys no Session authority of its own.
+            self.terminal_session(&input.target.session_id, false)
+                .await?;
+            tokio::select! { biased;
+                _ = context.scope().cancellation.cancelled() => return last.ok_or_else(|| failed("Process operation cancelled")),
+                _ = self.drain.cancelled() => return last.ok_or_else(|| failed("Process owner is draining")),
+                result = context.emit(value.clone()) => result.map_err(failed)?,
+            }
+            if terminal {
+                return Ok(value);
+            }
+            last = Some(value);
+            // A nonterminal event necessarily came from this original stream.
+            let stream = stream.as_mut().expect("active terminal output stream");
+            event = tokio::select! { biased;
+                _ = context.scope().cancellation.cancelled() => return last.ok_or_else(|| failed("Process operation cancelled")),
+                _ = self.drain.cancelled() => return last.ok_or_else(|| failed("Process owner is draining")),
+                event = stream.next() => project(event),
+            };
+        }
+    }
+}
+
+fn project(event: PtyStreamEvent) -> OutputEvent {
+    match event {
+        PtyStreamEvent::Data {
+            sequence,
+            size,
+            bytes,
+        } => OutputEvent::Data {
+            sequence,
+            size,
+            data_base64: STANDARD.encode(bytes.as_ref()),
+        },
+        PtyStreamEvent::Resize { sequence, size } => OutputEvent::Resize { sequence, size },
+        PtyStreamEvent::Gap {
+            after,
+            first_available,
+        } => OutputEvent::Gap {
+            after,
+            first_available,
+        },
+        PtyStreamEvent::Pending { after, size } => OutputEvent::Pending { after, size },
+        PtyStreamEvent::Closed { after, size } => OutputEvent::Closed { after, size },
+    }
+}
diff --git a/products/maka/cli/notices.json b/products/maka/cli/notices.json
index f755454c7e..b68171cca8 100644
--- a/products/maka/cli/notices.json
+++ b/products/maka/cli/notices.json
@@ -1,12 +1,12 @@
 {
   "inputs": {
-    "Cargo.lock": "8dc81c525e278e03876e1b3715453436714d3565fd23ee7dd285c2a74f1c6f13",
+    "Cargo.lock": "4e1814463750287b0766e24e8d205ca949daa92207a2b72d6dca8360862f56f8",
     "Cargo.toml": "9a2a29f608172df146deb8fea723605a6c66c3ca7af4871fffc6cc2509f3cefb",
     "package-lock.json": "a5af15aca21fabe0e1bdd48784167df4dc81cee17777d54861dcbd641168a4eb",
     "package.json": "38baf3beeb5876849ca16e31868d59bfaee769f260c7555b4a8a1847b92e28a1",
     "packages/plugin-sdk/src/configurations.ts": "20e587b47e84a31fc794176f85e7ac71c1b8b7f88a46947f9403011c307fff1e",
-    "packages/plugin-sdk/src/contracts.ts": "d1207817a5c183cc99defa614ef73b5534e4eb8a79152e8c1c9abfd406211579",
-    "packages/plugin-sdk/src/generated/runtime.ts": "66e0d36196e303b32d8464cdecec86c419da124d9f29ce473e0070855a1b8fe2",
+    "packages/plugin-sdk/src/contracts.ts": "b974da09de4fc8a7977eee8250f9eea1bb402151ff41649eaf0470f1f41ab6d0",
+    "packages/plugin-sdk/src/generated/runtime.ts": "a892e355ce7bd9d1115ecf40cd88d38b330187dfa621c5034f4da3867e1c00a7",
     "packages/plugin-sdk/src/index.ts": "27b68be5a29d1857f7fe361c8c678e77ba21413c79ec3870c1ce30d0faf9323f",
     "packages/plugin-sdk/src/native.ts": "d29328923ca55874fe1a0fc37985097196d805a09b56043c1a3379fd46bd5a20",
     "packages/plugin-sdk/src/ui.ts": "d6c64e9406c41ad6bbda73435b559aad24c31adb3c5bbb4ab6f49fff8a8330ca",
@@ -424,7 +424,7 @@
     "plugins/workspace/workspace-jev/engine/Cargo.toml": "35b4c9e8f155e62cff3df30073fff7fd2799a6d513bdb088d67ec4bd50a51dcb",
     "plugins/workspace/workspace-jev/native/Cargo.toml": "615f9e482da25590579771cd9be8d0220d83aef61e93d9b8679d480fc5c022c4",
     "plugins/workspace/workspace-jev/package.ts": "5f7e270323c49902523f02003b4ecc81434c07ac4d9087c8924cfb062fefd125",
-    "plugins/workspace/workspace-processes/native/Cargo.toml": "4a4e8711f2758926c49936bfa6a0da4521f9e7e19ce292db8600ca7fad31b977",
+    "plugins/workspace/workspace-processes/native/Cargo.toml": "524035ea8809a2e648446632d500815ac245f8fa124c93dc24b1952d8cbb7522",
     "plugins/workspace/workspace-processes/package.ts": "5c1bdbd1891b9c6ba389de5842c43877aefa816a29dad34c8f43b6e7678ace7d",
     "plugins/workspace/workspace-web/api/Cargo.toml": "e5a3a8c5c0fd0219db3b07efb8fae4e700b8bcde8892301a87635adfce615001",
     "plugins/workspace/workspace-web/native/Cargo.toml": "e40d175d4ed5300b1c43ecc3af89e9b90c806560b3bda65898a742b39559ca74",
diff --git a/tools/schema/src/lib.rs b/tools/schema/src/lib.rs
index 44ea8eace7..8aba299f77 100644
--- a/tools/schema/src/lib.rs
+++ b/tools/schema/src/lib.rs
@@ -309,6 +309,12 @@ pub fn catalog() -> Value {
         root::<maka_workspace_api::processes::Configuration>(&mut g),
         root::<maka_workspace_api::terminal::List>(&mut g),
         root::<maka_workspace_api::terminal::Target>(&mut g),
+        root::<maka_workspace_api::terminal::OutputRequest>(&mut g),
+        root::<maka_workspace_api::terminal::OutputEvent>(&mut g),
+        root::<maka_workspace_api::terminal::OutputUnavailable>(&mut g),
+        root::<maka_workspace_api::terminal::Outcome<maka_workspace_api::terminal::OutputEvent>>(
+            &mut g,
+        ),
         root::<maka_workspace_api::terminal::Start>(&mut g),
         root::<maka_workspace_api::terminal::Controller>(&mut g),
         root::<maka_workspace_api::terminal::Control>(&mut g),


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