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/08/30 00:39:14 UTC

(maka) branch main updated (8f33dcce1 -> 847e3c909)

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

M4n5ter pushed a change to branch main
in repository https://gitbox.apache.org/repos/asf/maka.git


    from 8f33dcce1 perf(ui): incrementally prepare streamed Markdown math (#4207)
     new b79cd0181 feat(runtime-host): scope shared session observation (#4195)
     new cfe87bac6 feat(desktop): share Runtime Host sessions with guests (#4196)
     new ce2052a35 feat(runtime-host): admit approved guest Turn requests (#4197)
     new 847e3c909 feat(desktop): approve guest Turn requests (#4198)

The 4 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.


Summary of changes:
 .../desktop-transcript-range-store.test.ts         |   8 +
 .../main/__tests__/runtime-host-client-uds.test.ts |   1 -
 .../src/main/__tests__/runtime-host-client.test.ts |   1 +
 .../runtime-host-collaboration-ipc-main.test.ts    | 100 ++++
 .../runtime-host-desktop-candidate.test.ts         | 174 ++++++-
 .../__tests__/runtime-host-profile-service.test.ts |  98 ++++
 ...time-host-session-catalog-running-turns.test.ts |  23 +-
 ...runtime-host-session-execution-ipc-main.test.ts |  11 +-
 .../runtime-host-session-observer.test.ts          |  12 +-
 .../__tests__/runtime-host-session-test-fixture.ts |   1 +
 .../src/main/runtime-host-artifacts-ipc-main.ts    | 104 ++--
 apps/desktop/src/main/runtime-host-boot.ts         |   2 +
 apps/desktop/src/main/runtime-host-client.ts       |  66 +++
 .../main/runtime-host-collaboration-invitation.ts  | 103 ++++
 .../main/runtime-host-collaboration-ipc-main.ts    | 145 ++++++
 .../src/main/runtime-host-desktop-candidate.ts     | 334 ++++++++-----
 .../src/main/runtime-host-local-remote-access.ts   |  23 +
 .../src/main/runtime-host-profile-service.ts       |  56 ++-
 .../main/runtime-host-session-catalog-ipc-main.ts  |  50 ++
 .../main/runtime-host-session-domains-ipc-main.ts  |  28 +-
 .../runtime-host-session-execution-ipc-main.ts     | 102 ++--
 .../runtime-host-session-observation-registry.ts   |  31 ++
 .../runtime-host-session-subscription-owner.ts     |  24 +-
 .../src/main/runtime-host-shell-runs-ipc-main.ts   |  56 ++-
 apps/desktop/src/preload/bridge-contract.d.ts      |  63 +++
 apps/desktop/src/preload/preload.ts                |  86 +++-
 apps/desktop/src/renderer/app-shell-effects.ts     |   9 +-
 apps/desktop/src/renderer/app-shell.tsx            | 139 ++++--
 .../renderer/locales/session-collaboration-copy.ts | 145 ++++++
 .../src/renderer/session-collaboration-dialog.tsx  | 468 ++++++++++++++++++
 .../src/renderer/session-turn-request-composer.tsx | 227 +++++++++
 .../settings/runtime-host-profiles-section.tsx     |  30 +-
 .../src/renderer/styles/settings/runtime-host.css  |  57 +++
 apps/desktop/src/renderer/styles/shell-layout.css  |  54 +++
 .../renderer/use-session-collaboration-dialog.ts   |  20 +-
 apps/desktop/src/renderer/use-shell-memory-pill.ts |   8 +-
 .../src/shared/desktop-session-projection.ts       |   2 +
 docs/astryx-surface-file-inventory.md              |   6 +-
 docs/astryx-surface-file-inventory.paths           |   2 +
 .../src/__tests__/runtime-host-cli-context.test.ts |   4 +-
 .../runtime-host-operator-command.test.ts          |   2 +
 .../__tests__/runtime-host-profile-command.test.ts |   4 +-
 packages/core/src/session.ts                       |  11 +
 .../src/__tests__/artifact-coordinator.test.ts     | 112 +++++
 .../src/__tests__/authenticated-websocket.test.ts  |  60 ++-
 .../src/__tests__/connection-session.test.ts       |   1 +
 .../fixtures/session-transcript-reader.ts          |  34 ++
 .../src/__tests__/host-change-feed.test.ts         |  22 +-
 .../src/__tests__/host-profile.test.ts             |  12 +-
 .../src/__tests__/root-turn-coordinator.test.ts    |  43 ++
 .../__tests__/runtime-resource-coordinator.test.ts |  85 +++-
 .../session-collaboration-authority.test.ts        | 539 ++++++++++++++++++++-
 .../session-continuity-coordinator.test.ts         | 270 ++++++++++-
 .../__tests__/session-subscription-client.test.ts  |  58 +++
 .../src/__tests__/session-transcript-pager.test.ts | 159 ++++++
 .../__tests__/session-transcript-protocol.test.ts  |   2 +
 packages/runtime-host/src/client/host-profile.ts   |  66 ++-
 packages/runtime-host/src/client/index.ts          |   2 +
 .../src/client/session-subscription.ts             |   4 +-
 packages/runtime-host/src/protocol/index.ts        |   6 +-
 packages/runtime-host/src/protocol/operations.ts   |   3 +
 .../runtime-host/src/protocol/session-catalog.ts   |  75 +++
 .../src/protocol/session-collaboration.ts          | 284 +++++++++++
 .../src/protocol/session-continuity.ts             |   8 +-
 .../src/protocol/session-transcript.ts             |  14 +-
 packages/runtime-host/src/protocol/turn.ts         |   2 +-
 .../runtime-host/src/server/access-authority.ts    | 514 +++++++++++++++++++-
 .../src/server/access-credential-store.ts          |  62 ++-
 .../src/server/artifact-coordinator.ts             |  63 ++-
 .../src/server/connection-authority.ts             |   6 +-
 .../runtime-host/src/server/connection-session.ts  |  27 +-
 .../src/server/execution-composition.ts            |  32 +-
 .../runtime-host/src/server/host-change-feed.ts    |  65 ++-
 packages/runtime-host/src/server/host-kernel.ts    |  64 ++-
 .../src/server/operation-dispatcher.ts             |  32 ++
 .../src/server/root-admission-owner.ts             |   4 +
 .../src/server/root-turn-coordinator.ts            |  23 +-
 .../src/server/runtime-resource-coordinator.ts     | 145 +++---
 .../src/server/session-catalog-coordinator.ts      |  78 +++
 .../src/server/session-continuity-coordinator.ts   | 160 +++++-
 .../src/server/session-transcript-pager.ts         | 108 ++++-
 .../src/server/session-transcript-reader.ts        |   8 +
 .../session-turn-access-request-coordinator.ts     | 143 ++++++
 .../src/server/shared-session-transcript.ts        | 182 +++++++
 .../storage/src/__tests__/session-store.test.ts    |   9 +
 packages/storage/src/agent-run-store.ts            |  62 ++-
 packages/storage/src/execution-stores.ts           |   6 +
 packages/storage/src/session-store.ts              |  27 ++
 .../storage/src/sqlite-session-metadata-store.ts   | 115 +++++
 packages/ui/src/icons.tsx                          |   1 +
 packages/ui/src/materialize.ts                     |  16 +-
 packages/ui/src/session-history-list.tsx           |   4 +-
 packages/ui/src/titlebar-session-identity.tsx      |  23 +
 scripts/check-app-shell-hooks.mjs                  |   1 +
 94 files changed, 6235 insertions(+), 496 deletions(-)
 create mode 100644 apps/desktop/src/main/__tests__/runtime-host-collaboration-ipc-main.test.ts
 create mode 100644 apps/desktop/src/main/runtime-host-collaboration-invitation.ts
 create mode 100644 apps/desktop/src/main/runtime-host-collaboration-ipc-main.ts
 create mode 100644 apps/desktop/src/renderer/locales/session-collaboration-copy.ts
 create mode 100644 apps/desktop/src/renderer/session-collaboration-dialog.tsx
 create mode 100644 apps/desktop/src/renderer/session-turn-request-composer.tsx
 copy packages/runtime-host/src/server/message-queue-capacity.ts => apps/desktop/src/renderer/use-session-collaboration-dialog.ts (67%)
 create mode 100644 packages/runtime-host/src/server/session-turn-access-request-coordinator.ts
 create mode 100644 packages/runtime-host/src/server/shared-session-transcript.ts


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


(maka) 01/04: feat(runtime-host): scope shared session observation (#4195)

Posted by wy...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

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

commit b79cd018151e6d38dbe0b253cc2eda2d5aba253f
Author: Wang <m4...@gmail.com>
AuthorDate: Sun Aug 30 08:38:50 2026 +0800

    feat(runtime-host): scope shared session observation (#4195)
    
    Expose a Host-private-field-free shared Session projection and grant-fenced transcript and live subscriptions, including immediate closure after observation revocation.
    
    Generated-by: OpenAI Codex
---
 .../desktop-transcript-range-store.test.ts         |   8 +
 .../src/main/__tests__/runtime-host-client.test.ts |   1 +
 .../runtime-host-session-observer.test.ts          |   2 +
 .../__tests__/runtime-host-session-test-fixture.ts |   1 +
 .../runtime-host-session-subscription-owner.ts     |   8 +-
 packages/core/src/session.ts                       |  11 +
 .../src/__tests__/artifact-coordinator.test.ts     | 112 +++++++++
 .../src/__tests__/authenticated-websocket.test.ts  |  56 ++++-
 .../src/__tests__/connection-session.test.ts       |   1 +
 .../fixtures/session-transcript-reader.ts          |  34 +++
 .../src/__tests__/root-turn-coordinator.test.ts    |   1 +
 .../__tests__/runtime-resource-coordinator.test.ts |  85 ++++++-
 .../session-collaboration-authority.test.ts        |   7 +
 .../session-continuity-coordinator.test.ts         | 270 ++++++++++++++++++++-
 .../__tests__/session-subscription-client.test.ts  |  58 +++++
 .../src/__tests__/session-transcript-pager.test.ts | 159 ++++++++++++
 .../__tests__/session-transcript-protocol.test.ts  |   2 +
 .../src/client/session-subscription.ts             |   4 +-
 packages/runtime-host/src/protocol/index.ts        |   4 +-
 packages/runtime-host/src/protocol/operations.ts   |   1 +
 .../runtime-host/src/protocol/session-catalog.ts   |  75 ++++++
 .../src/protocol/session-continuity.ts             |   8 +-
 .../src/protocol/session-transcript.ts             |  14 +-
 .../runtime-host/src/server/access-authority.ts    |  13 +
 .../src/server/access-credential-store.ts          |   7 +
 .../src/server/artifact-coordinator.ts             |  63 ++++-
 .../runtime-host/src/server/connection-session.ts  |  10 +-
 .../src/server/execution-composition.ts            |   9 +
 packages/runtime-host/src/server/host-kernel.ts    |   7 +
 .../src/server/operation-dispatcher.ts             |   2 +
 .../src/server/runtime-resource-coordinator.ts     | 145 ++++++-----
 .../src/server/session-catalog-coordinator.ts      |  78 ++++++
 .../src/server/session-continuity-coordinator.ts   | 160 ++++++++++--
 .../src/server/session-transcript-pager.ts         | 108 ++++++++-
 .../src/server/session-transcript-reader.ts        |   8 +
 .../src/server/shared-session-transcript.ts        | 182 ++++++++++++++
 .../storage/src/__tests__/session-store.test.ts    |   9 +
 packages/storage/src/execution-stores.ts           |   4 +
 packages/storage/src/session-store.ts              |  27 +++
 .../storage/src/sqlite-session-metadata-store.ts   | 115 +++++++++
 packages/ui/src/materialize.ts                     |  16 +-
 41 files changed, 1776 insertions(+), 109 deletions(-)

diff --git a/apps/desktop/src/main/__tests__/desktop-transcript-range-store.test.ts b/apps/desktop/src/main/__tests__/desktop-transcript-range-store.test.ts
index aea43a202..561672602 100644
--- a/apps/desktop/src/main/__tests__/desktop-transcript-range-store.test.ts
+++ b/apps/desktop/src/main/__tests__/desktop-transcript-range-store.test.ts
@@ -230,6 +230,7 @@ test('keeps a bounded contiguous window while moving between history and the tai
     events: { async *[Symbol.asyncIterator]() {} },
     transcriptBootstrap: {
       throughSequence: 4,
+      durableCoverage: 'complete',
       overlayMessageCount: 0,
       durable: bootstrapPage,
       overlay: { ...page(null), source: 'overlay' },
@@ -294,6 +295,7 @@ test('delivers a mid-session tail append even while a history window is resident
     events: { async *[Symbol.asyncIterator]() {} },
     transcriptBootstrap: {
       throughSequence: 4,
+      durableCoverage: 'complete',
       overlayMessageCount: 0,
       durable: bootstrapPage,
       overlay: { ...page(null), source: 'overlay' },
@@ -364,6 +366,7 @@ test('does not resurrect a discarded replica when a tail re-anchor is in flight'
     events: { async *[Symbol.asyncIterator]() {} },
     transcriptBootstrap: {
       throughSequence: 4,
+      durableCoverage: 'complete',
       overlayMessageCount: 0,
       durable: bootstrapPage,
       overlay: { ...page(null), source: 'overlay' },
@@ -446,6 +449,7 @@ test('does not resurrect a discarded replica when a history load is in flight',
     events: { async *[Symbol.asyncIterator]() {} },
     transcriptBootstrap: {
       throughSequence: 4,
+      durableCoverage: 'complete',
       overlayMessageCount: 0,
       durable: bootstrapPage,
       overlay: { ...page(null), source: 'overlay' },
@@ -518,6 +522,7 @@ test('does not drive a discarded replica terminal when a contiguous catch-up is
     events: { async *[Symbol.asyncIterator]() {} },
     transcriptBootstrap: {
       throughSequence: 4,
+      durableCoverage: 'complete',
       overlayMessageCount: 0,
       durable: bootstrapPage,
       overlay: { ...page(null, 4), source: 'overlay' },
@@ -572,6 +577,7 @@ test('loads a history target with newer messages available below it', async () =
     events: { async *[Symbol.asyncIterator]() {} },
     transcriptBootstrap: {
       throughSequence: 4,
+      durableCoverage: 'complete',
       overlayMessageCount: 0,
       durable: bootstrapPage,
       overlay: { ...transcriptPage('older', null, 4), source: 'overlay' },
@@ -624,6 +630,7 @@ test('keeps an oversized transcript sparse while moving between indexed prompts'
     events: { async *[Symbol.asyncIterator]() {} },
     transcriptBootstrap: {
       throughSequence: 15,
+      durableCoverage: 'complete',
       overlayMessageCount: 0,
       durable: bootstrapPage,
       overlay: { ...transcriptPage('older', null, 15), source: 'overlay' },
@@ -727,6 +734,7 @@ test('keeps history resident when an active overlay uses its own cache budget',
     events: { async *[Symbol.asyncIterator]() {} },
     transcriptBootstrap: {
       throughSequence: 1,
+      durableCoverage: 'complete',
       overlayMessageCount: 1,
       durable: bootstrapPage,
       overlay: { ...transcriptPage('older', null, 1), source: 'overlay' },
diff --git a/apps/desktop/src/main/__tests__/runtime-host-client.test.ts b/apps/desktop/src/main/__tests__/runtime-host-client.test.ts
index 01fd41646..4318b9d00 100644
--- a/apps/desktop/src/main/__tests__/runtime-host-client.test.ts
+++ b/apps/desktop/src/main/__tests__/runtime-host-client.test.ts
@@ -163,6 +163,7 @@ function subscription(
     activeAssistantStreams: [],
     transcriptBootstrap: {
       throughSequence: null,
+      durableCoverage: 'complete',
       overlayMessageCount: 0,
       durable: emptyTranscriptPage(sessionId, 'durable'),
       overlay: emptyTranscriptPage(sessionId, 'overlay'),
diff --git a/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts b/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts
index 5d0fb4e4f..b8c7e90dc 100644
--- a/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts
+++ b/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts
@@ -594,6 +594,7 @@ test('fences transcript range failures across same-source replica recovery', asy
           events,
           transcriptBootstrap: {
             throughSequence: 1,
+            durableCoverage: 'complete',
             overlayMessageCount: 0,
             durable: bootstrap,
             overlay: { ...bootstrap, source: 'overlay', nextCursor: null },
@@ -887,6 +888,7 @@ test('finishes transcript open and replays a stale range request after replaceme
           events,
           transcriptBootstrap: {
             throughSequence: 0,
+            durableCoverage: 'complete',
             overlayMessageCount: 0,
             durable: {
               kind: 'page',
diff --git a/apps/desktop/src/main/__tests__/runtime-host-session-test-fixture.ts b/apps/desktop/src/main/__tests__/runtime-host-session-test-fixture.ts
index c213a0ee4..6ea30bb2d 100644
--- a/apps/desktop/src/main/__tests__/runtime-host-session-test-fixture.ts
+++ b/apps/desktop/src/main/__tests__/runtime-host-session-test-fixture.ts
@@ -46,6 +46,7 @@ export function runtimeHostSessionFixture(input: {
     activeAssistantStreams: input.activeAssistantStreams ?? [],
     transcriptBootstrap: input.transcriptBootstrap ?? {
       throughSequence: null,
+      durableCoverage: 'complete',
       overlayMessageCount: 0,
       durable: emptyPage(sessionId, 'durable'),
       overlay: emptyPage(sessionId, 'overlay'),
diff --git a/apps/desktop/src/main/runtime-host-session-subscription-owner.ts b/apps/desktop/src/main/runtime-host-session-subscription-owner.ts
index 21d95402d..096683bf5 100644
--- a/apps/desktop/src/main/runtime-host-session-subscription-owner.ts
+++ b/apps/desktop/src/main/runtime-host-session-subscription-owner.ts
@@ -404,11 +404,13 @@ export class RuntimeHostSessionSubscriptionOwner {
 }
 
 function subscriptionClosedError(
-  reason: "slow_consumer" | "session_removed",
+  reason: "access_revoked" | "slow_consumer" | "session_removed",
 ): Error {
-  return reason === "session_removed"
+  return reason !== "slow_consumer"
     ? new SessionRemovedSubscriptionError(
-        "Runtime Host Session was removed while it was observed",
+        reason === "access_revoked"
+          ? "Runtime Host Session access was revoked"
+          : "Runtime Host Session was removed while it was observed",
       )
     : new RuntimeHostSubscriptionError(
         "slow_consumer",
diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts
index 686ad8493..cbfc7efec 100644
--- a/packages/core/src/session.ts
+++ b/packages/core/src/session.ts
@@ -780,6 +780,17 @@ export function userFacingText(message: Pick<UserMessage, 'text' | 'displayText'
   return message.displayText ?? message.text;
 }
 
+const USER_VISIBLE_SESSION_SYSTEM_NOTES = new Set([
+  'context_compacted',
+  'context_compaction_failed_open',
+  'step_limit',
+]);
+
+/** Closed policy for system notes that are part of the user-visible transcript. */
+export function isUserVisibleSessionSystemNote(kind: string): boolean {
+  return USER_VISIBLE_SESSION_SYSTEM_NOTES.has(kind);
+}
+
 export interface AssistantMessage {
   type: 'assistant';
   id: string;
diff --git a/packages/runtime-host/src/__tests__/artifact-coordinator.test.ts b/packages/runtime-host/src/__tests__/artifact-coordinator.test.ts
index c07def370..409f0c649 100644
--- a/packages/runtime-host/src/__tests__/artifact-coordinator.test.ts
+++ b/packages/runtime-host/src/__tests__/artifact-coordinator.test.ts
@@ -401,6 +401,118 @@ test('Artifact query streams complete content in bounded ordered chunks', async
   }
 });
 
+test('Session Guests can read only shared attachment Artifacts from their granted Session', async () => {
+  const root = await mkdtemp(join(tmpdir(), 'maka-artifact-shared-read-'));
+  const capability = await resolveStorageRoot({ path: root, kind: 'interactive' });
+  const owner = await tryAcquireInteractiveRootOwner(capability);
+  assert.ok(owner);
+  try {
+    const store = await openInteractiveArtifactStoreForWrite(owner.lease);
+    await store.recover();
+    await store.create({
+      id: 'shared-image',
+      sessionId: 'session-1',
+      turnId: 'turn-1',
+      name: 'shared.png',
+      kind: 'image',
+      mimeType: 'image/png',
+      source: 'user_upload',
+      content: Buffer.from('image'),
+      now: 1,
+    });
+    await store.create({
+      id: 'private-artifact',
+      sessionId: 'session-1',
+      turnId: 'turn-1',
+      name: 'private.txt',
+      kind: 'file',
+      source: 'provider_request_capture',
+      content: Buffer.from('private'),
+      now: 2,
+    });
+    let active = true;
+    let revokeAfterAuthorization = false;
+    const coordinator = new HostArtifactCoordinator(
+      store,
+      () => assert.fail('successful read must not request Host drain'),
+      new SessionAdmissionGate(),
+      { probeSessionRemoval: async () => ({ kind: 'present' }) },
+      Date.now,
+      {
+        activeSessionGrant: () => {
+          if (!active) return;
+          if (revokeAfterAuthorization) {
+            revokeAfterAuthorization = false;
+            queueMicrotask(() => {
+              active = false;
+            });
+          }
+          return {
+            kind: 'session_observation',
+            grantId: 'grant-1',
+            principalId: 'guest-1',
+            sessionId: 'session-1',
+            createdAt: '2026-08-30T00:00:00.000Z',
+          };
+        },
+      },
+    );
+    const guest = {
+      ...connectionContext,
+      principal: 'guest-1',
+      principalKind: 'session_guest' as const,
+    };
+
+    const visible = await coordinator.handlers['artifact.query'](
+      { kind: 'get', sessionId: 'session-1', artifactId: 'shared-image' },
+      guest,
+    );
+    assert.equal(visible.ok && visible.result.kind === 'artifact', true);
+    assert.equal(
+      (
+        await coordinator.handlers['artifact.query'](
+          { kind: 'get', sessionId: 'session-1', artifactId: 'private-artifact' },
+          guest,
+        )
+      ).ok,
+      false,
+    );
+    assert.equal(
+      (
+        await coordinator.handlers['artifact.query'](
+          { kind: 'list_start', sessionId: 'session-1' },
+          guest,
+        )
+      ).ok,
+      false,
+    );
+
+    revokeAfterAuthorization = true;
+    assert.equal(
+      (
+        await coordinator.handlers['artifact.query'](
+          { kind: 'get', sessionId: 'session-1', artifactId: 'shared-image' },
+          guest,
+        )
+      ).ok,
+      false,
+    );
+    assert.equal(
+      (
+        await coordinator.handlers['artifact.query'](
+          { kind: 'get', sessionId: 'session-1', artifactId: 'shared-image' },
+          guest,
+        )
+      ).ok,
+      false,
+    );
+    store.close();
+  } finally {
+    await owner.close();
+    await rm(root, { recursive: true, force: true });
+  }
+});
+
 function digest(bytes: Uint8Array): `sha256:${string}` {
   return `sha256:${createHash('sha256').update(bytes).digest('hex')}`;
 }
diff --git a/packages/runtime-host/src/__tests__/authenticated-websocket.test.ts b/packages/runtime-host/src/__tests__/authenticated-websocket.test.ts
index 878675d6c..fb75ef9e9 100644
--- a/packages/runtime-host/src/__tests__/authenticated-websocket.test.ts
+++ b/packages/runtime-host/src/__tests__/authenticated-websocket.test.ts
@@ -39,6 +39,7 @@ import {
   INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID,
   RUNTIME_HOST_PROTOCOL_VERSION,
   SESSION_CATALOG_LIVE_RUN_STATE_SCHEMA_VERSION,
+  decodeCollaborationInvitationCode,
   type RequestFrame,
 } from '../protocol/index.js';
 import { openRuntimeHostAccessAuthority } from '../server/access-authority.js';
@@ -70,6 +71,7 @@ test('one Local IPC owner and one authenticated WebSocket Client control the sam
   });
   let local: RuntimeHostConnection | undefined;
   let remote: RuntimeHostConnection | undefined;
+  let guest: RuntimeHostConnection | undefined;
   try {
     local = requireConnection(await connectRuntimeHost({ rootPath: root, protocol: PROTOCOL }));
     const issued = await local.request('access.credential.issue', {
@@ -217,6 +219,58 @@ test('one Local IPC owner and one authenticated WebSocket Client control the sam
       modelTarget: { kind: 'default' },
     });
     assert.ok(!('kind' in created));
+    const preparedGuest = await local.request('collaboration.invitation.prepare', {
+      sessionId: 'shared-session',
+      grantKinds: ['session_observation'],
+    });
+    const guestInvitation = decodeCollaborationInvitationCode(preparedGuest.invitationCode);
+    const pendingGuest = await connectRemoteRuntimeHost({
+      url,
+      credential: guestInvitation.credential,
+      clientInstanceId: 'guest-client',
+      expectedRootId: capability.rootId,
+      compositionId: INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID,
+      protocol: PROTOCOL,
+    });
+    assert.equal(pendingGuest.kind, 'connected', JSON.stringify(pendingGuest));
+    if (pendingGuest.kind !== 'connected') assert.fail('Session Guest did not connect');
+    await pendingGuest.connection.request('access.credential.finalize', {});
+    await pendingGuest.connection.close();
+    const activeGuest = await connectRemoteRuntimeHost({
+      url,
+      credential: guestInvitation.credential,
+      clientInstanceId: 'guest-client',
+      expectedRootId: capability.rootId,
+      compositionId: INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID,
+      protocol: PROTOCOL,
+    });
+    assert.equal(activeGuest.kind, 'connected', JSON.stringify(activeGuest));
+    if (activeGuest.kind !== 'connected') assert.fail('Session Guest did not reconnect');
+    guest = activeGuest.connection;
+    const sharedCatalog = await guest.request('session.shared.query', {});
+    assert.equal(sharedCatalog.session?.id, 'shared-session');
+    assert.equal('workspace' in sharedCatalog.session!, false);
+    await assert.rejects(
+      guest.request('session.catalog.query', { kind: 'list_start' }),
+      (error: unknown) =>
+        error instanceof RuntimeHostOperationError && error.code === 'unauthorized',
+    );
+    const guestSubscription = await guest.openSessionSubscription({
+      sessionId: 'shared-session',
+      transcript: { kind: 'none' },
+    });
+    const observationGrant = preparedGuest.grants.find(
+      (grant) => grant.kind === 'session_observation',
+    )!;
+    await local.request('collaboration.grant.revoke', {
+      grantId: observationGrant.grantId,
+    });
+    const closed = await guestSubscription[Symbol.asyncIterator]().next();
+    assert.equal(closed.done, false);
+    assert.equal(closed.value?.kind, 'subscription.closed');
+    if (closed.value?.kind === 'subscription.closed') {
+      assert.equal(closed.value.reason, 'access_revoked');
+    }
     assert.deepEqual(
       await remote.request('session.catalog.query', {
         kind: 'get',
@@ -409,7 +463,7 @@ test('one Local IPC owner and one authenticated WebSocket Client control the sam
       { credentialId: candidate.credentialId, revoked: true },
     );
   } finally {
-    await Promise.allSettled([remote?.close(), local?.close()]);
+    await Promise.allSettled([guest?.close(), remote?.close(), local?.close()]);
     await host.close().catch(() => undefined);
     await rm(join(resolveRootControlNamespace(), capability.rootId), {
       recursive: true,
diff --git a/packages/runtime-host/src/__tests__/connection-session.test.ts b/packages/runtime-host/src/__tests__/connection-session.test.ts
index c749055c1..f6c071579 100644
--- a/packages/runtime-host/src/__tests__/connection-session.test.ts
+++ b/packages/runtime-host/src/__tests__/connection-session.test.ts
@@ -1632,6 +1632,7 @@ function transcriptBootstrapFor(sessionId: string) {
   const contents = Buffer.from('t'.repeat(16 * 1024));
   return {
     throughSequence: 0,
+    durableCoverage: 'complete' as const,
     overlayMessageCount: 0,
     durable: {
       kind: 'page' as const,
diff --git a/packages/runtime-host/src/__tests__/fixtures/session-transcript-reader.ts b/packages/runtime-host/src/__tests__/fixtures/session-transcript-reader.ts
index bc86eb995..8d2dbecb9 100644
--- a/packages/runtime-host/src/__tests__/fixtures/session-transcript-reader.ts
+++ b/packages/runtime-host/src/__tests__/fixtures/session-transcript-reader.ts
@@ -101,6 +101,40 @@ export function transcriptReader(
       }
       return { throughSequence, fragments, rawBytes, next };
     },
+    readDurableRecords: async (_sessionId, request) => {
+      const throughSequence =
+        request.throughSequence === undefined
+          ? durable.length === 0
+            ? null
+            : durable.length - 1
+          : request.throughSequence;
+      if (throughSequence === null) {
+        return { throughSequence: null, records: [], nextPosition: null };
+      }
+      const position = request.position ?? (request.direction === 'older' ? throughSequence : 0);
+      const candidates = durable
+        .map((message, sequence) => ({ sequence, message }))
+        .filter(
+          ({ sequence }) =>
+            sequence <= throughSequence &&
+            (request.direction === 'older' ? sequence <= position : sequence >= position),
+        )
+        .sort((left, right) =>
+          request.direction === 'older'
+            ? right.sequence - left.sequence
+            : left.sequence - right.sequence,
+        );
+      const records = candidates.slice(0, request.maxMessages);
+      const last = records.at(-1);
+      return {
+        throughSequence,
+        records,
+        nextPosition:
+          last && records.length < candidates.length
+            ? last.sequence + (request.direction === 'older' ? -1 : 1)
+            : null,
+      };
+    },
     readDurableMessagesById: async (_sessionId, request) =>
       request.throughSequence === null
         ? []
diff --git a/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts b/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts
index a0f2f6284..791b9f8a6 100644
--- a/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts
+++ b/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts
@@ -5162,6 +5162,7 @@ function operationContext(
     hostEpoch,
     connectionId,
     principal: 'local_os_user' as const,
+    principalKind: 'local_owner' as const,
     acquireResidency,
   };
 }
diff --git a/packages/runtime-host/src/__tests__/runtime-resource-coordinator.test.ts b/packages/runtime-host/src/__tests__/runtime-resource-coordinator.test.ts
index 4716466df..a9597a56b 100644
--- a/packages/runtime-host/src/__tests__/runtime-resource-coordinator.test.ts
+++ b/packages/runtime-host/src/__tests__/runtime-resource-coordinator.test.ts
@@ -161,6 +161,72 @@ describe('Host Runtime Resource coordinator', () => {
     assert.equal(harness.listReadCount, 0);
   });
 
+  test('fences Guest reads to the exact active observation grant', async () => {
+    let active = true;
+    let releaseRead!: () => void;
+    let markReadStarted!: () => void;
+    const readBarrier = new Promise<void>((resolve) => {
+      releaseRead = resolve;
+    });
+    const readStarted = new Promise<void>((resolve) => {
+      markReadStarted = resolve;
+    });
+    const harness = createHarness({
+      sessionAccessAuthority: {
+        activeSessionGrant(principalId, sessionId, kind) {
+          return active &&
+            principalId === 'guest-1' &&
+            sessionId === SESSION_ID &&
+            kind === 'session_observation'
+            ? {
+                kind,
+                grantId: 'grant-1',
+                principalId,
+                sessionId,
+                createdAt: '2026-01-01T00:00:00.000Z',
+              }
+            : undefined;
+        },
+      },
+    });
+    harness.pointReadBarrier = readBarrier;
+    harness.pointReadStarted = markReadStarted;
+    const guest = guestConnection('guest-1');
+
+    const outsideGrant = await harness.coordinator.handlers['runtime.resource.query'](
+      { kind: 'list_start', sessionId: 'session-2' },
+      guest,
+    );
+    assert.equal(outsideGrant.ok, false);
+    assert.equal(!outsideGrant.ok && outsideGrant.error.code, 'not_found');
+    assert.equal(harness.listReadCount, 0);
+
+    harness.updates = [resourceUpdate(0), resourceUpdate(1, { sessionId: 'parent-session' })];
+    const visible = await harness.coordinator.handlers['runtime.resource.query'](
+      { kind: 'list_start', sessionId: SESSION_ID },
+      guest,
+    );
+    assert.equal(visible.ok, true);
+    assert.deepEqual(
+      visible.ok && visible.result.kind === 'page'
+        ? visible.result.resources.map((resource) => resource.result.ref)
+        : [],
+      [shellRef(0)],
+    );
+
+    const pending = harness.coordinator.handlers['runtime.resource.query'](
+      { kind: 'get', sessionId: SESSION_ID, ref: shellRef(0) },
+      guest,
+    );
+    await readStarted;
+    assert.equal(harness.pointReadCount, 1);
+    active = false;
+    releaseRead();
+    const revoked = await pending;
+    assert.equal(revoked.ok, false);
+    assert.equal(!revoked.ok && revoked.error.code, 'not_found');
+  });
+
   test('drains for canonical state failure but keeps projection failure scoped to its query', async () => {
     const harness = createHarness();
     harness.updates = [
@@ -638,7 +704,12 @@ describe('Host Runtime Resource coordinator', () => {
   });
 });
 
-function createHarness(options: Pick<HostRuntimeResourceCoordinatorInput, 'resolveShell'> = {}) {
+function createHarness(
+  options: Pick<
+    HostRuntimeResourceCoordinatorInput,
+    'resolveShell' | 'sessionAccessAuthority'
+  > = {},
+) {
   let backgroundCompletion: ShellRunBashInput['onCompletion'];
   let currentSnapshot = ptySnapshot();
   let lastStartedSnapshot: ShellRunSnapshotResult | undefined;
@@ -651,6 +722,8 @@ function createHarness(options: Pick<HostRuntimeResourceCoordinatorInput, 'resol
     drainCount: 0,
     listReadCount: 0,
     pointReadCount: 0,
+    pointReadBarrier: undefined as Promise<void> | undefined,
+    pointReadStarted: undefined as (() => void) | undefined,
     stateReadFailure: undefined as Error | undefined,
     inspectFailure: undefined as Error | undefined,
     activeResidencies: 0,
@@ -748,6 +821,8 @@ function createHarness(options: Pick<HostRuntimeResourceCoordinatorInput, 'resol
       },
       getShellRunUpdate: async (_sessionId, ref) => {
         state.pointReadCount += 1;
+        state.pointReadStarted?.();
+        await state.pointReadBarrier;
         if (state.stateReadFailure) throw state.stateReadFailure;
         return structuredClone(state.updates.find((update) => update.result.ref === ref) ?? null);
       },
@@ -805,6 +880,14 @@ function connection(connectionId: string): ConnectionContext {
   };
 }
 
+function guestConnection(principal: string): ConnectionContext {
+  return {
+    ...connection('guest-connection'),
+    principal,
+    principalKind: 'session_guest',
+  };
+}
+
 function backgroundInput(onCompletion?: ShellRunBashInput['onCompletion']): ShellRunBashInput {
   return {
     sessionId: SESSION_ID,
diff --git a/packages/runtime-host/src/__tests__/session-collaboration-authority.test.ts b/packages/runtime-host/src/__tests__/session-collaboration-authority.test.ts
index 2ac5c2ecd..9f3573e37 100644
--- a/packages/runtime-host/src/__tests__/session-collaboration-authority.test.ts
+++ b/packages/runtime-host/src/__tests__/session-collaboration-authority.test.ts
@@ -44,6 +44,13 @@ test('Session Guest invitation, grants, and revocation form one durable authorit
     await authority.finalize(credentialId, 'guest-client');
     assert.deepEqual(authority.authenticate(invitation.credential)?.operationGrants, [
       'host.status',
+      'artifact.query',
+      'runtime.resource.query',
+      'session.shared.query',
+      'subscription.open',
+      'subscription.close',
+      'session.transcript.page',
+      'session.transcript.overlay.release',
     ]);
 
     const observation = prepared.grants.find((grant) => grant.kind === 'session_observation')!;
diff --git a/packages/runtime-host/src/__tests__/session-continuity-coordinator.test.ts b/packages/runtime-host/src/__tests__/session-continuity-coordinator.test.ts
index ccb25b811..04b4852b3 100644
--- a/packages/runtime-host/src/__tests__/session-continuity-coordinator.test.ts
+++ b/packages/runtime-host/src/__tests__/session-continuity-coordinator.test.ts
@@ -47,6 +47,16 @@ import { transcriptReader } from './fixtures/session-transcript-reader.js';
 
 const HOST_EPOCH = 'host-epoch';
 const SESSION_ID = 'session-1';
+const TEST_OWNER_IDENTITY = {
+  principalId: 'local_owner',
+  principalKind: 'local_owner',
+} as const;
+type TestIdentity =
+  | typeof TEST_OWNER_IDENTITY
+  | {
+      readonly principalId: string;
+      readonly principalKind: 'session_guest';
+    };
 
 test('open is an inactive publication barrier and live sequence starts at nextSequence', async () => {
   const read = deferred<CanonicalSessionProjection | null>();
@@ -88,6 +98,53 @@ test('open is an inactive publication barrier and live sequence starts at nextSe
   coordinator.close();
 });
 
+test('Guest revocation wins a concurrent subscription open', async () => {
+  const read = deferred<CanonicalSessionProjection | null>();
+  const grant = {
+    kind: 'session_observation' as const,
+    grantId: 'grant-1',
+    principalId: 'guest-1',
+    sessionId: SESSION_ID,
+    createdAt: '2026-08-30T00:00:00.000Z',
+  };
+  let active = true;
+  let publishRevocation: ((revoked: typeof grant) => void) | undefined;
+  const coordinator = new SessionContinuityCoordinator(
+    HOST_EPOCH,
+    () => read.promise,
+    new SessionAdmissionGate(),
+    undefined,
+    undefined,
+    undefined,
+    {
+      activeSessionGrant: () => (active ? grant : undefined),
+      subscribeGrantRevocations: (listener) => {
+        publishRevocation = listener;
+        return () => undefined;
+      },
+    },
+  );
+  coordinator.attachConnection('guest-connection', new RecordingSink());
+
+  const opening = coordinator.handlers['subscription.open'](
+    { sessionId: SESSION_ID, transcript: { kind: 'none' } },
+    connectionContext('guest-connection', {
+      principalId: 'guest-1',
+      principalKind: 'session_guest',
+    }),
+  );
+  await delayImmediate();
+  active = false;
+  publishRevocation?.(grant);
+  read.resolve(canonical());
+
+  assert.deepEqual(await opening, {
+    ok: false,
+    error: { code: 'not_found', message: 'Session was not found' },
+  });
+  coordinator.close();
+});
+
 test('forwards the durable steering echo to subscribers as a session event', async () => {
   const sink = new RecordingSink();
   const coordinator = new SessionContinuityCoordinator(
@@ -121,6 +178,86 @@ test('forwards the durable steering echo to subscribers as a session event', asy
   coordinator.close();
 });
 
+test('projects model-only user content out of Guest queue and steering frames', async () => {
+  const grant = {
+    kind: 'session_observation' as const,
+    grantId: 'grant-1',
+    principalId: 'guest-1',
+    sessionId: SESSION_ID,
+    createdAt: '2026-08-30T00:00:00.000Z',
+  };
+  const sink = new RecordingSink();
+  const coordinator = new SessionContinuityCoordinator(
+    HOST_EPOCH,
+    async () =>
+      canonical({
+        queue: {
+          hostEpoch: HOST_EPOCH,
+          queueRevision: 1,
+          steering: [],
+          followup: [
+            {
+              entryId: 'entry-1',
+              messageId: 'message-1',
+              content: { text: 'private skill body', displayText: 'visible prompt' },
+              placement: 'next_turn',
+              state: 'queued',
+            },
+          ],
+        },
+      }),
+    new SessionAdmissionGate(),
+    undefined,
+    undefined,
+    undefined,
+    {
+      activeSessionGrant: () => grant,
+      subscribeGrantRevocations: () => () => undefined,
+    },
+  );
+  const connection = coordinator.attachConnection('guest-connection', sink);
+  const opened = await open(
+    coordinator,
+    'guest-connection',
+    { kind: 'none' },
+    {
+      principalId: grant.principalId,
+      principalKind: 'session_guest',
+    },
+  );
+  assert.deepEqual(opened.snapshot.queue.followup[0]?.content, { text: 'visible prompt' });
+  connection.activate(opened.subscriptionId);
+  coordinator.enqueueAgentGraphChanged({
+    rootSessionId: SESSION_ID,
+    graphId: 'private-graph',
+    reason: 'observation',
+  });
+  await delayImmediate();
+  assert.equal(sink.frames.length, 0);
+  await coordinator.acceptRuntimeEvent(SESSION_ID, 'run-1', {
+    type: 'steering_message',
+    id: 'steering-event-1',
+    turnId: 'turn-1',
+    ts: 7,
+    messageId: 'steering-message-1',
+    content: { text: 'private skill body', displayText: 'visible steer' },
+  });
+
+  const frame = sink.frames.find((candidate) => candidate.kind === 'subscription.session_event');
+  assert.equal(frame?.kind, 'subscription.session_event');
+  if (frame?.kind === 'subscription.session_event') {
+    assert.deepEqual(frame.event, {
+      type: 'steering_message',
+      id: 'steering-event-1',
+      turnId: 'turn-1',
+      ts: 7,
+      messageId: 'steering-message-1',
+      content: { text: 'visible steer' },
+    });
+  }
+  coordinator.close();
+});
+
 test('open snapshot includes pending Interactions from the canonical projection', async () => {
   const pending = pendingInteraction();
   const coordinator = new SessionContinuityCoordinator(
@@ -597,10 +734,24 @@ test('coalesces typed domain invalidations without publishing continuity project
 
 test('fans one bounded Runtime Resource burst out to an inherited Session view', async () => {
   const childSessionId = 'child-session';
+  const grant = {
+    kind: 'session_observation' as const,
+    grantId: 'grant-1',
+    principalId: 'guest-1',
+    sessionId: childSessionId,
+    createdAt: '2026-08-30T00:00:00.000Z',
+  };
   const coordinator = new SessionContinuityCoordinator(
     HOST_EPOCH,
     async (sessionId) => canonicalFor(sessionId),
     new SessionAdmissionGate(),
+    undefined,
+    undefined,
+    undefined,
+    {
+      activeSessionGrant: () => grant,
+      subscribeGrantRevocations: () => () => undefined,
+    },
   );
   const sink = new RecordingSink();
   const connection = coordinator.attachConnection('connection-1', sink);
@@ -611,6 +762,18 @@ test('fans one bounded Runtime Resource burst out to an inherited Session view',
   assert.equal(outcome.ok, true);
   if (!outcome.ok) return;
   connection.activate(outcome.result.subscriptionId);
+  const guestSink = new RecordingSink();
+  const guestConnection = coordinator.attachConnection('guest-connection', guestSink);
+  const guestOutcome = await coordinator.handlers['subscription.open'](
+    { sessionId: childSessionId, transcript: { kind: 'none' } },
+    connectionContext('guest-connection', {
+      principalId: grant.principalId,
+      principalKind: 'session_guest',
+    }),
+  );
+  assert.equal(guestOutcome.ok, true);
+  if (!guestOutcome.ok) return;
+  guestConnection.activate(guestOutcome.result.subscriptionId);
   const updates = Array.from({ length: 64 }, (_, index) => {
     const update = shellRunUpdate({
       sessionId: 'parent-session',
@@ -619,6 +782,7 @@ test('fans one bounded Runtime Resource burst out to an inherited Session view',
     update.result.ref = `shell:run-${index}`;
     return update;
   });
+  updates[updates.length - 1]!.sessionId = childSessionId;
 
   for (const update of updates) coordinator.enqueueRuntimeResourceChanged(update);
   await waitFor(() => sink.frames.length === 1);
@@ -635,6 +799,20 @@ test('fans one bounded Runtime Resource burst out to an inherited Session view',
       ref: update.result.ref,
     })),
   });
+  assert.deepEqual(guestSink.frames[0], {
+    kind: 'subscription.session_domain_changed',
+    hostEpoch: HOST_EPOCH,
+    subscriptionId: guestOutcome.result.subscriptionId,
+    sequence: 1,
+    sessionId: childSessionId,
+    domain: 'runtime_resource',
+    resources: [
+      {
+        sourceSessionId: childSessionId,
+        ref: updates[updates.length - 1]!.result.ref,
+      },
+    ],
+  });
   coordinator.close();
 });
 
@@ -1672,6 +1850,87 @@ test('an in-flight transcript page cannot outlive its owning connection', async
   coordinator.close();
 });
 
+test('an in-flight transcript page cannot outlive its Guest observation grant', async () => {
+  const message = assistantMessage('界'.repeat(20_000));
+  const continued = deferred<void>();
+  const baseReader = transcriptReader([message]);
+  let blockPage = false;
+  const reader: SessionTranscriptReader = {
+    ...baseReader,
+    readDurableRecords: async (sessionId, request) => {
+      if (blockPage) await continued.promise;
+      return baseReader.readDurableRecords(sessionId, request);
+    },
+  };
+  const grant = {
+    kind: 'session_observation' as const,
+    grantId: 'grant-1',
+    principalId: 'guest-1',
+    sessionId: SESSION_ID,
+    createdAt: '2026-08-30T00:00:00.000Z',
+  };
+  let active = true;
+  let publishRevocation: ((revoked: typeof grant) => void) | undefined;
+  const coordinator = new SessionContinuityCoordinator(
+    HOST_EPOCH,
+    async () => canonical(),
+    new SessionAdmissionGate(),
+    undefined,
+    reader,
+    undefined,
+    {
+      activeSessionGrant: () => (active ? grant : undefined),
+      subscribeGrantRevocations: (listener) => {
+        publishRevocation = listener;
+        return () => undefined;
+      },
+    },
+  );
+  coordinator.attachConnection('guest-connection', new RecordingSink());
+  const opened = await open(
+    coordinator,
+    'guest-connection',
+    {
+      kind: 'tail',
+      maxBytes: SESSION_TRANSCRIPT_BOOTSTRAP_MAX_BYTES,
+    },
+    {
+      principalId: grant.principalId,
+      principalKind: 'session_guest',
+    },
+  );
+  const cursor = opened.transcript?.durable.nextCursor;
+  assert.ok(cursor);
+  if (!opened.transcript || !cursor) return;
+
+  blockPage = true;
+  const reading = coordinator.handlers['session.transcript.page'](
+    {
+      subscriptionId: opened.subscriptionId,
+      source: 'durable',
+      direction: 'older',
+      throughSequence: opened.transcript.throughSequence,
+      cursor,
+      anchorSequence: null,
+      maxBytes: 1024,
+    },
+    connectionContext('guest-connection', {
+      principalId: grant.principalId,
+      principalKind: 'session_guest',
+    }),
+  );
+  await delayImmediate();
+  active = false;
+  publishRevocation?.(grant);
+  continued.resolve();
+
+  assert.deepEqual(await reading, {
+    ok: false,
+    error: { code: 'not_found', message: 'Session subscription was not found' },
+  });
+  coordinator.close();
+});
+
 test('a durable append refresh advances transcript before its completion event', async () => {
   const durable = [assistantMessage('first')];
   const reader = transcriptReader(durable);
@@ -2048,10 +2307,11 @@ async function open(
   transcript: { readonly kind: 'none' } | { readonly kind: 'tail'; readonly maxBytes: number } = {
     kind: 'none',
   },
+  identity: TestIdentity = TEST_OWNER_IDENTITY,
 ) {
   const outcome = await coordinator.handlers['subscription.open'](
     { sessionId: SESSION_ID, transcript },
-    connectionContext(connectionId),
+    connectionContext(connectionId, identity),
   );
   if (!outcome.ok) throw new Error(outcome.error.message);
   assert.equal(outcome.ok, true);
@@ -2153,11 +2413,15 @@ async function consumeBootstrapOverlay(
   );
 }
 
-function connectionContext(connectionId: string): ConnectionContext {
+function connectionContext(
+  connectionId: string,
+  identity: TestIdentity = TEST_OWNER_IDENTITY,
+): ConnectionContext {
   return {
     hostEpoch: HOST_EPOCH,
     connectionId,
-    principal: 'local_os_user',
+    principal: identity.principalId,
+    principalKind: identity.principalKind,
     acquireResidency: () => ({ release() {} }),
   };
 }
diff --git a/packages/runtime-host/src/__tests__/session-subscription-client.test.ts b/packages/runtime-host/src/__tests__/session-subscription-client.test.ts
index 1a777646e..6ca64650b 100644
--- a/packages/runtime-host/src/__tests__/session-subscription-client.test.ts
+++ b/packages/runtime-host/src/__tests__/session-subscription-client.test.ts
@@ -392,6 +392,7 @@ test('reassembles a large message from bounded backward pages', async () => {
       const openRequest = await acceptConnectionAndReadOpen(transport, hostEpoch, rootId);
       const opened = openResult(hostEpoch, 'subscription-fragmented', {
         throughSequence: 0,
+        durableCoverage: 'complete',
         overlayMessageCount: 0,
         durable: transcriptPage({
           rawBytes: encoded.byteLength - splitAt,
@@ -472,6 +473,7 @@ test('decodes one bounded page without walking the remaining transcript', async
   const subscription = new ClientSessionSubscription(
     openResult('host-1', 'subscription-bounded-page', {
       throughSequence: 4,
+      durableCoverage: 'complete',
       overlayMessageCount: 0,
       durable: {
         ...transcriptPage({
@@ -811,6 +813,7 @@ test('rejects a durable sequence gap', async () => {
   const gap = new ClientSessionSubscription(
     openResult('host-1', 'subscription-gap', {
       throughSequence: 1,
+      durableCoverage: 'complete',
       overlayMessageCount: 0,
       durable: {
         ...transcriptPage({
@@ -832,6 +835,54 @@ test('rejects a durable sequence gap', async () => {
   );
 });
 
+test('loads a projected durable transcript with intentionally sparse sequences', async () => {
+  const messages = [0, 2].map((sequence) =>
+    Buffer.from(
+      JSON.stringify({
+        type: 'user',
+        id: `user-${sequence}`,
+        turnId: 'turn-1',
+        ts: sequence + 1,
+        text: `visible-${sequence}`,
+      }),
+      'utf8',
+    ),
+  );
+  const subscription = new ClientSessionSubscription(
+    openResult('host-1', 'subscription-projected', {
+      throughSequence: 2,
+      durableCoverage: 'projected',
+      overlayMessageCount: 0,
+      durable: {
+        ...transcriptPage({
+          rawBytes: messages.reduce((total, message) => total + message.byteLength, 0),
+          fragments: messages
+            .map((message, index) => ({
+              kind: 'durable' as const,
+              sequence: index * 2,
+              byteOffset: 0,
+              totalBytes: message.byteLength,
+              payloadDigest: null,
+              data: message.toString('base64'),
+            }))
+            .reverse(),
+        }),
+        throughSequence: 2,
+      },
+      overlay: { ...transcriptPage({ source: 'overlay' }), throughSequence: 2 },
+    }),
+    async () => undefined,
+    async () => {
+      throw new Error('unexpected page request');
+    },
+  );
+
+  assert.deepEqual(
+    (await subscription.loadTranscript(decodeStoredMessage)).map((message) => message.id),
+    ['user-0', 'user-2'],
+  );
+});
+
 test('rejects a durable message that does not match its payload digest', async () => {
   const message = Buffer.from(
     JSON.stringify({
@@ -846,6 +897,7 @@ test('rejects a durable message that does not match its payload digest', async (
   const subscription = new ClientSessionSubscription(
     openResult('host-1', 'subscription-digest-mismatch', {
       throughSequence: 0,
+      durableCoverage: 'complete',
       overlayMessageCount: 0,
       durable: transcriptPage({
         rawBytes: message.byteLength,
@@ -910,6 +962,7 @@ test('rejects a transcript cursor that does not advance', async () => {
   const subscription = new ClientSessionSubscription(
     openResult('host-1', 'subscription-stuck-cursor', {
       throughSequence: 0,
+      durableCoverage: 'complete',
       overlayMessageCount: 0,
       durable: repeated,
       overlay: transcriptPage({ source: 'overlay' }),
@@ -939,6 +992,7 @@ test('rejects an overlay that terminates before its declared high-water', async
   const subscription = new ClientSessionSubscription(
     openResult('host-1', 'subscription-truncated-overlay', {
       throughSequence: null,
+      durableCoverage: 'complete',
       overlayMessageCount: 2,
       durable: { ...transcriptPage(), throughSequence: null },
       overlay: {
@@ -1030,6 +1084,7 @@ test('acknowledges a complete overlay before waiting for durable continuation pa
   const subscription = new ClientSessionSubscription(
     openResult('host-1', 'subscription-overlay-release-before-durable', {
       throughSequence: 0,
+      durableCoverage: 'complete',
       overlayMessageCount: 1,
       durable: transcriptPage({
         rawBytes: durableMessage.byteLength - split,
@@ -1103,6 +1158,7 @@ test('close stops transcript pagination after the in-flight page', async () => {
   const subscription = new ClientSessionSubscription(
     openResult('host-1', 'subscription-closing', {
       throughSequence: 0,
+      durableCoverage: 'complete',
       overlayMessageCount: 0,
       durable: transcriptPage({
         rawBytes: Math.floor(message.byteLength / 2),
@@ -1293,6 +1349,7 @@ function openResult(
 function transcriptBootstrap(message: Buffer): SessionTranscriptBootstrap {
   return {
     throughSequence: 0,
+    durableCoverage: 'complete',
     overlayMessageCount: 0,
     durable: transcriptPage({
       rawBytes: message.byteLength,
@@ -1314,6 +1371,7 @@ function transcriptBootstrap(message: Buffer): SessionTranscriptBootstrap {
 function overlayBootstrap(message: Buffer): SessionTranscriptBootstrap {
   return {
     throughSequence: null,
+    durableCoverage: 'complete',
     overlayMessageCount: 1,
     durable: { ...transcriptPage(), throughSequence: null },
     overlay: {
diff --git a/packages/runtime-host/src/__tests__/session-transcript-pager.test.ts b/packages/runtime-host/src/__tests__/session-transcript-pager.test.ts
index e4ea72480..9dd2b10fc 100644
--- a/packages/runtime-host/src/__tests__/session-transcript-pager.test.ts
+++ b/packages/runtime-host/src/__tests__/session-transcript-pager.test.ts
@@ -28,6 +28,7 @@ import {
   updateSubscriberTranscriptHighWater,
 } from '../server/session-transcript-pager.js';
 import type { SessionTranscriptReader } from '../server/session-transcript-reader.js';
+import { projectSharedSessionTranscriptMessage } from '../server/shared-session-transcript.js';
 import { transcriptReader } from './fixtures/session-transcript-reader.js';
 
 test('reads newly durable messages forward from an announced watermark', async () => {
@@ -41,6 +42,7 @@ test('reads newly durable messages forward from an announced watermark', async (
     rootTurn: null,
     activeAssistantStreams: [],
     maxBytes: 1024,
+    projection: 'owner',
   });
   assert.equal(bootstrap.throughSequence, 1);
 
@@ -68,6 +70,151 @@ test('reads newly durable messages forward from an announced watermark', async (
   assert.equal(page.nextCursor, null);
 });
 
+test('projects durable and active transcript records before sharing them', async () => {
+  const durable: StoredMessage[] = [
+    {
+      ...assistantMessage(0),
+      providerOptions: { replay: 'private' },
+      thinking: {
+        text: 'visible thought',
+        signature: 'private-signature',
+        providerOptions: { replay: 'private' },
+      },
+    },
+    {
+      type: 'tool_result',
+      id: 'result-1',
+      turnId: 'turn-0',
+      ts: 2,
+      toolUseId: 'tool-1',
+      isError: false,
+      content: { kind: 'text', text: 'visible result' },
+      modelVisibility: 'hidden',
+      providerOutput: { replay: 'private' },
+    },
+    {
+      type: 'system_note',
+      id: 'audit-1',
+      ts: 3,
+      kind: 'mode_change',
+      data: { previousSessionId: 'private-session' },
+    },
+    {
+      type: 'user',
+      id: 'user-1',
+      turnId: 'turn-0',
+      ts: 4,
+      text: 'private composed skill instructions',
+      displayText: 'visible attachment',
+      steeringEventId: 'steering-event-1',
+      attachments: [
+        {
+          kind: 'code',
+          name: 'visible.ts',
+          mimeType: 'text/typescript',
+          bytes: 7,
+          ref: { kind: 'session_file', sessionId: 'session-1', relativePath: 'visible.ts' },
+        },
+        {
+          kind: 'code',
+          name: 'private.ts',
+          mimeType: 'text/typescript',
+          bytes: 8,
+          ref: { kind: 'external_file', absolutePath: '/private/private.ts' },
+        },
+      ],
+    },
+  ];
+  const overlay: StoredMessage[] = [
+    {
+      ...assistantMessage(1),
+      providerOptions: { replay: 'private' },
+    },
+  ];
+  const reader = transcriptReader(durable, overlay);
+  const owner = await createSessionTranscriptBootstrap({
+    reader,
+    sessionId: 'session-1',
+    subscriptionId: 'owner',
+    throughSequence: 2,
+    rootTurn: null,
+    activeAssistantStreams: [],
+    maxBytes: 16 * 1024,
+    projection: 'owner',
+  });
+  const shared = await createSessionTranscriptBootstrap({
+    reader,
+    sessionId: 'session-1',
+    subscriptionId: 'shared',
+    throughSequence: 3,
+    rootTurn: null,
+    activeAssistantStreams: [],
+    maxBytes: 16 * 1024,
+    projection: 'shared',
+  });
+
+  assert.equal(
+    (decodeBootstrap(owner.bootstrap.durable)[0] as { data?: unknown }).data !== undefined,
+    true,
+  );
+  assert.equal(
+    (decodeBootstrap(owner.bootstrap.overlay)[0] as { providerOptions?: unknown })
+      .providerOptions !== undefined,
+    true,
+  );
+  const sharedDurable = decodeBootstrap(shared.bootstrap.durable);
+  assert.deepEqual(
+    sharedDurable.map((message) => message.type),
+    ['user', 'tool_result', 'assistant'],
+  );
+  const sharedAttachments = sharedDurable[0]?.attachments;
+  assert.deepEqual(
+    Array.isArray(sharedAttachments)
+      ? sharedAttachments.map((item) => (item as { name: string }).name)
+      : [],
+    ['visible.ts'],
+  );
+  assert.equal(sharedDurable[0]?.text, 'visible attachment');
+  assert.equal('displayText' in sharedDurable[0]!, false);
+  assert.equal(sharedDurable[0]?.steeringEventId, 'steering-event-1');
+  assert.equal('providerOutput' in sharedDurable[1]!, false);
+  assert.equal('providerOptions' in sharedDurable[2]!, false);
+  assert.deepEqual(sharedDurable[2]!.thinking, { text: 'visible thought' });
+  assert.equal('providerOptions' in decodeBootstrap(shared.bootstrap.overlay)[0]!, false);
+  const projectedState = projectSharedSessionTranscriptMessage(
+    {
+      type: 'turn_state',
+      id: 'state-1',
+      turnId: 'turn-0',
+      ts: 5,
+      status: 'aborted',
+      abortedAt: 5,
+      abortSource: 'stop_button',
+      partialOutputRetained: true,
+    },
+    'session-1',
+  );
+  assert.equal(projectedState?.type, 'turn_state');
+  if (projectedState?.type === 'turn_state') {
+    assert.equal(projectedState.abortSource, 'stop_button');
+  }
+  const projectedInput = projectSharedSessionTranscriptMessage(
+    {
+      type: 'tool_call',
+      id: 'tool-1',
+      turnId: 'turn-0',
+      ts: 6,
+      toolName: 'WriteStdin',
+      args: { ref: 'shell-1', input: 'secret=sk-example-value' },
+    },
+    'session-1',
+  );
+  assert.equal(projectedInput?.type, 'tool_call');
+  if (projectedInput?.type === 'tool_call') {
+    assert.equal(JSON.stringify(projectedInput.args).includes('sk-example-value'), false);
+  }
+});
+
 test('rejects cursor tampering and cross-subscription replay', async () => {
   const reader = transcriptReader([userMessage(0, 'x'.repeat(2_000))]);
   const first = await createSessionTranscriptBootstrap({
@@ -78,6 +225,7 @@ test('rejects cursor tampering and cross-subscription replay', async () => {
     rootTurn: null,
     activeAssistantStreams: [],
     maxBytes: 128,
+    projection: 'owner',
   });
   const second = await createSessionTranscriptBootstrap({
     reader,
@@ -87,6 +235,7 @@ test('rejects cursor tampering and cross-subscription replay', async () => {
     rootTurn: null,
     activeAssistantStreams: [],
     maxBytes: 128,
+    projection: 'owner',
   });
   const cursor = first.bootstrap.durable.nextCursor;
   assert.ok(cursor);
@@ -127,6 +276,7 @@ test('keeps a durable continuation when overlay bytes reduce the bootstrap budge
     rootTurn: null,
     activeAssistantStreams: [],
     maxBytes: Buffer.byteLength(JSON.stringify(durable[0]), 'utf8') * 2,
+    projection: 'owner',
   });
   assert.ok(bootstrap.overlay.rawBytes > 0);
   assert.ok(bootstrap.durable.nextCursor);
@@ -143,6 +293,7 @@ test('shrinks the raw bootstrap until it fits its aggregate encoded budget', asy
     activeAssistantStreams: [],
     maxBytes: 16 * 1024,
     maxEncodedBytes: 4 * 1024,
+    projection: 'owner',
   });
   assert.ok(Buffer.byteLength(JSON.stringify(bootstrap), 'utf8') <= 4 * 1024);
   assert.ok(bootstrap.durable.nextCursor);
@@ -230,3 +381,11 @@ function assistantMessage(index: number): Extract<StoredMessage, { type: 'assist
     thinking: { text: `thinking-${index}`, signature: '' },
   };
 }
+
+function decodeBootstrap(
+  page: Awaited<ReturnType<typeof createSessionTranscriptBootstrap>>['bootstrap']['durable'],
+): Array<Record<string, unknown>> {
+  return page.fragments.map((fragment) =>
+    JSON.parse(Buffer.from(fragment.data, 'base64').toString('utf8')),
+  );
+}
diff --git a/packages/runtime-host/src/__tests__/session-transcript-protocol.test.ts b/packages/runtime-host/src/__tests__/session-transcript-protocol.test.ts
index d6b9886a6..78e73ce6b 100644
--- a/packages/runtime-host/src/__tests__/session-transcript-protocol.test.ts
+++ b/packages/runtime-host/src/__tests__/session-transcript-protocol.test.ts
@@ -70,6 +70,7 @@ test('Session transcript protocol accepts bounded correlated pages and bootstrap
 
   const bootstrap = {
     throughSequence: 3,
+    durableCoverage: 'complete' as const,
     overlayMessageCount: 0,
     durable: { ...page, direction: 'older' as const },
     overlay: {
@@ -181,6 +182,7 @@ test('Session transcript protocol rejects malformed and uncorrelated values', ()
     () =>
       decodeSessionTranscriptBootstrap({
         throughSequence: 3,
+        durableCoverage: 'complete',
         overlayMessageCount: 0,
         durable: page,
         overlay: { ...page, source: 'overlay', throughSequence: 2 },
diff --git a/packages/runtime-host/src/client/session-subscription.ts b/packages/runtime-host/src/client/session-subscription.ts
index 569261d0a..3a61369a5 100644
--- a/packages/runtime-host/src/client/session-subscription.ts
+++ b/packages/runtime-host/src/client/session-subscription.ts
@@ -317,7 +317,9 @@ export class ClientSessionSubscription
     }
     const overlay = await this.#consumeTranscriptOverlay(bootstrap);
     const durable = await this.#loadTranscriptSource(bootstrap.durable);
-    assertCompleteIdentities(durable, bootstrap.throughSequence);
+    if (bootstrap.durableCoverage === 'complete') {
+      assertCompleteIdentities(durable, bootstrap.throughSequence);
+    }
     const messages = durable.map((entry) => entry.value);
     const indexById = new Map<string, number>();
     for (const [index, message] of messages.entries()) {
diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts
index 7fa57c355..9b1663234 100644
--- a/packages/runtime-host/src/protocol/index.ts
+++ b/packages/runtime-host/src/protocol/index.ts
@@ -95,7 +95,9 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const;
 export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const;
 // Increment when the same protocol version no longer guarantees safe Client-Host
 // interoperability. Mismatches are rejected before domain commands are admitted.
-export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 69 as const;
+export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 70 as const;
+// 70: Session Guest connections receive resource-scoped shared catalog and
+// continuity projections. Older peers cannot enforce the Session grant fence.
 // 69: Runtime Host access authority recognizes restricted Session Guest
 // principals and typed Session collaboration grants. Older Hosts would either
 // reject the new operations or misclassify the authenticated principal.
diff --git a/packages/runtime-host/src/protocol/operations.ts b/packages/runtime-host/src/protocol/operations.ts
index 67c05a29f..ed7af8d93 100644
--- a/packages/runtime-host/src/protocol/operations.ts
+++ b/packages/runtime-host/src/protocol/operations.ts
@@ -305,6 +305,7 @@ export const REMOTE_OWNER_OPERATION_GRANTS = Object.freeze([
   'session.create',
   'session.execution_boundary.query',
   'session.lifecycle.set',
+  'session.shared.query',
   'session.metadata.update',
   'session.read_marker.set',
   'session.recap.generate',
diff --git a/packages/runtime-host/src/protocol/session-catalog.ts b/packages/runtime-host/src/protocol/session-catalog.ts
index 62c5309a4..da2ffcbbd 100644
--- a/packages/runtime-host/src/protocol/session-catalog.ts
+++ b/packages/runtime-host/src/protocol/session-catalog.ts
@@ -253,8 +253,29 @@ export interface UnsupportedLegacySessionCatalogRecord {
   readonly reason: 'not_wire_representable';
 }
 
+export interface SharedSessionCatalogProjection {
+  readonly kind: 'shared_session';
+  readonly id: string;
+  readonly revision: number;
+  readonly createdAt: number;
+  readonly activityAt: number;
+  readonly name: string;
+  readonly lastMessageAt?: number;
+  readonly lastMessagePreview?: string;
+  readonly status: SessionStatus;
+  readonly liveRunState?: SessionCatalogLiveRunState;
+  readonly blockedReason?: SessionBlockedReason;
+  readonly statusUpdatedAt?: number;
+}
+
 export type SessionCatalogItem = SessionCatalogProjection | UnsupportedLegacySessionCatalogRecord;
 
+export type SharedSessionCatalogQueryInput = Record<string, never>;
+
+export interface SharedSessionCatalogQueryResult {
+  readonly session: SharedSessionCatalogProjection | null;
+}
+
 export type SessionCatalogQueryResult =
   | {
       readonly kind: 'page';
@@ -281,6 +302,17 @@ export type SessionUpdateResult =
     };
 
 export const SESSION_CATALOG_OPERATION_SPECS = {
+  'session.shared.query': defineOperation<
+    SharedSessionCatalogQueryInput,
+    SharedSessionCatalogQueryResult,
+    (typeof QUERY_ERRORS)[number]
+  >({
+    mode: 'query',
+    availability: 'ready',
+    errors: QUERY_ERRORS,
+    decodeInput: decodeSharedSessionCatalogQueryInput,
+    decodeOutput: decodeSharedSessionCatalogQueryResult,
+  }),
   'session.catalog.query': defineOperation<
     SessionCatalogQueryInput,
     SessionCatalogQueryResult,
@@ -371,6 +403,49 @@ export const SESSION_CATALOG_OPERATION_SPECS = {
   }),
 } as const;
 
+function decodeSharedSessionCatalogQueryInput(value: unknown): SharedSessionCatalogQueryInput {
+  requireExactRecord(value, 'shared Session catalog query input', []);
+  return {};
+}
+
+function decodeSharedSessionCatalogQueryResult(value: unknown): SharedSessionCatalogQueryResult {
+  const record = requireExactRecord(value, 'shared Session catalog query result', ['session']);
+  const session =
+    record.session === null ? null : decodeSharedSessionCatalogProjection(record.session);
+  requireEncodedByteLimit(
+    session,
+    'shared Session catalog result',
+    SESSION_CATALOG_RESULT_MAX_BYTES,
+  );
+  return { session };
+}
+
+export function decodeSharedSessionCatalogProjection(
+  value: unknown,
+): SharedSessionCatalogProjection {
+  const exact = requireShapedRecord(
+    value,
+    'shared Session catalog projection',
+    ['kind', 'id', 'revision', 'createdAt', 'activityAt', 'name', 'status'],
+    ['lastMessageAt', 'lastMessagePreview', 'liveRunState', 'blockedReason', 'statusUpdatedAt'],
+  );
+  if (exact.kind !== 'shared_session') throw invalidProtocolFrame('Invalid shared Session kind');
+  return {
+    kind: 'shared_session',
+    id: requireEntityId(exact.id, 'Session id'),
+    revision: positiveRevision(exact.revision, 'Session revision'),
+    createdAt: timestamp(exact.createdAt, 'Session createdAt'),
+    activityAt: timestamp(exact.activityAt, 'Session activityAt'),
+    name: sessionName(exact.name),
+    ...optionalTimestamp(exact, 'lastMessageAt'),
+    ...optionalText(exact, 'lastMessagePreview', SESSION_CATALOG_PREVIEW_MAX_BYTES),
+    status: decodeSessionStatus(exact.status),
+    ...optionalLiveRunState(exact),
+    ...optionalBlockedReason(exact),
+    ...optionalTimestamp(exact, 'statusUpdatedAt'),
+  };
+}
+
 export function decodeSessionExecutionBoundaryQueryInput(
   value: unknown,
 ): SessionExecutionBoundaryQueryInput {
diff --git a/packages/runtime-host/src/protocol/session-continuity.ts b/packages/runtime-host/src/protocol/session-continuity.ts
index 116f875ca..5cd42d792 100644
--- a/packages/runtime-host/src/protocol/session-continuity.ts
+++ b/packages/runtime-host/src/protocol/session-continuity.ts
@@ -293,7 +293,7 @@ export interface AgentGraphChangedFrame extends SubscriptionEnvelope {
 
 export interface SubscriptionClosedFrame extends SubscriptionEnvelope {
   kind: 'subscription.closed';
-  reason: 'slow_consumer' | 'session_removed';
+  reason: 'slow_consumer' | 'session_removed' | 'access_revoked';
 }
 
 export type SubscriptionFrame =
@@ -492,7 +492,11 @@ export function decodeSubscriptionFrame(value: unknown): SubscriptionFrame {
       'sequence',
       'reason',
     ]);
-    if (record.reason !== 'slow_consumer' && record.reason !== 'session_removed') {
+    if (
+      record.reason !== 'slow_consumer' &&
+      record.reason !== 'session_removed' &&
+      record.reason !== 'access_revoked'
+    ) {
       throw invalidProtocolFrame('Invalid subscription close reason');
     }
     frame = { kind: record.kind, ...envelope, reason: record.reason };
diff --git a/packages/runtime-host/src/protocol/session-transcript.ts b/packages/runtime-host/src/protocol/session-transcript.ts
index 6369f7334..d54f79149 100644
--- a/packages/runtime-host/src/protocol/session-transcript.ts
+++ b/packages/runtime-host/src/protocol/session-transcript.ts
@@ -69,6 +69,8 @@ export interface SessionTranscriptPage {
 
 export interface SessionTranscriptBootstrap {
   readonly throughSequence: number | null;
+  /** Whether every durable sequence is present or policy projection may leave gaps. */
+  readonly durableCoverage: 'complete' | 'projected';
   readonly overlayMessageCount: number;
   readonly durable: SessionTranscriptPage;
   readonly overlay: SessionTranscriptPage;
@@ -186,6 +188,7 @@ export function decodeSessionTranscriptPageInput(value: unknown): SessionTranscr
 export function decodeSessionTranscriptBootstrap(value: unknown): SessionTranscriptBootstrap {
   const bootstrap = requireExactRecord(value, 'Session transcript bootstrap', [
     'throughSequence',
+    'durableCoverage',
     'overlayMessageCount',
     'durable',
     'overlay',
@@ -194,6 +197,9 @@ export function decodeSessionTranscriptBootstrap(value: unknown): SessionTranscr
     bootstrap.throughSequence === null
       ? null
       : requireCount(bootstrap.throughSequence, 'Session transcript watermark');
+  if (bootstrap.durableCoverage !== 'complete' && bootstrap.durableCoverage !== 'projected') {
+    throw invalidProtocolFrame('Invalid Session transcript durable coverage');
+  }
   const overlayMessageCount = requireCount(
     bootstrap.overlayMessageCount,
     'Session transcript overlay message count',
@@ -216,7 +222,13 @@ export function decodeSessionTranscriptBootstrap(value: unknown): SessionTranscr
   if (durable.rawBytes + overlay.rawBytes > SESSION_TRANSCRIPT_BOOTSTRAP_MAX_BYTES) {
     throw invalidProtocolFrame('Session transcript bootstrap exceeds byte limit');
   }
-  return { throughSequence, overlayMessageCount, durable, overlay };
+  return {
+    throughSequence,
+    durableCoverage: bootstrap.durableCoverage,
+    overlayMessageCount,
+    durable,
+    overlay,
+  };
 }
 
 export function decodeSessionTranscriptPage(value: unknown): SessionTranscriptPage {
diff --git a/packages/runtime-host/src/server/access-authority.ts b/packages/runtime-host/src/server/access-authority.ts
index 0d25be84f..3ccdfad22 100644
--- a/packages/runtime-host/src/server/access-authority.ts
+++ b/packages/runtime-host/src/server/access-authority.ts
@@ -107,6 +107,10 @@ export interface RuntimeHostAccessAuthority {
     sessionId: string,
     kind: SessionCollaborationGrantKind,
   ): SessionCollaborationGrant | undefined;
+  activeSessionGrantForPrincipal(
+    principalId: string,
+    kind: SessionCollaborationGrantKind,
+  ): SessionCollaborationGrant | undefined;
   subscribeRevocations(listener: (credentialId: string) => void): () => void;
   subscribeGrantRevocations(listener: (grant: SessionCollaborationGrant) => void): () => void;
   close(): Promise<void>;
@@ -312,6 +316,15 @@ class FileRuntimeHostAccessAuthority implements RuntimeHostAccessAuthority {
     );
   }
 
+  activeSessionGrantForPrincipal(
+    principalId: string,
+    kind: SessionCollaborationGrantKind,
+  ): SessionCollaborationGrant | undefined {
+    return this.#file.sessionGrants.find(
+      (grant) => grant.principalId === principalId && grant.kind === kind,
+    );
+  }
+
   prepareRotation(
     input: AccessCredentialRotationPrepareInput,
   ): Promise<AccessCredentialRotationPrepareResult> {
diff --git a/packages/runtime-host/src/server/access-credential-store.ts b/packages/runtime-host/src/server/access-credential-store.ts
index 4b18e86cf..e4fe1b819 100644
--- a/packages/runtime-host/src/server/access-credential-store.ts
+++ b/packages/runtime-host/src/server/access-credential-store.ts
@@ -57,6 +57,13 @@ export const ACCESS_FILE_NAME = 'runtime-host-access.json';
 
 export const SESSION_GUEST_OPERATION_GRANTS = Object.freeze([
   'host.status',
+  'artifact.query',
+  'runtime.resource.query',
+  'session.shared.query',
+  'subscription.open',
+  'subscription.close',
+  'session.transcript.page',
+  'session.transcript.overlay.release',
 ] as const satisfies readonly OperationKey[]);
 
 export interface StoredAccessCredential {
diff --git a/packages/runtime-host/src/server/artifact-coordinator.ts b/packages/runtime-host/src/server/artifact-coordinator.ts
index 76cd0a483..195d5fb40 100644
--- a/packages/runtime-host/src/server/artifact-coordinator.ts
+++ b/packages/runtime-host/src/server/artifact-coordinator.ts
@@ -42,7 +42,8 @@ import {
   type OperationOutcome,
 } from '../protocol/index.js';
 import { encodeArtifactProjection } from '../protocol/artifact.js';
-import type { ArtifactOperationHandlerMap } from './operation-dispatcher.js';
+import type { RuntimeHostAccessAuthority } from './access-authority.js';
+import type { ArtifactOperationHandlerMap, ConnectionContext } from './operation-dispatcher.js';
 import { SessionAdmissionGate } from './session-admission-gate.js';
 import type { SessionPresenceReader } from './session-presence.js';
 import { ConnectionBoundChunkUploads } from './connection-bound-chunk-uploads.js';
@@ -50,6 +51,7 @@ import { ConnectionBoundChunkUploads } from './connection-bound-chunk-uploads.js
 const MAX_ACTIVE_ARTIFACT_UPLOADS = 16;
 const MAX_STAGED_ARTIFACT_UPLOAD_BYTES = 128 * 1024 * 1024;
 const ARTIFACT_UPLOAD_TTL_MS = 5 * 60 * 1000;
+const SHARED_ARTIFACT_SOURCES = new Set(['user_upload', 'tool_result']);
 
 interface ArtifactUploadMetadata {
   readonly attachmentKind: AttachmentRef['kind'];
@@ -63,8 +65,8 @@ export class HostArtifactCoordinator {
   readonly handlers: ArtifactOperationHandlerMap = {
     'artifact.ingest': (input, context) =>
       this.#sessionAdmission.run(input.sessionId, () => this.#ingest(input, context)),
-    'artifact.query': (input) =>
-      this.#sessionAdmission.run(input.sessionId, () => this.#query(input)),
+    'artifact.query': (input, context) =>
+      this.#sessionAdmission.run(input.sessionId, () => this.#query(input, context)),
     'artifact.delete': (input) =>
       this.#sessionAdmission.run(input.sessionId, () => this.#delete(input)),
   };
@@ -73,6 +75,9 @@ export class HostArtifactCoordinator {
   readonly #requestDrain: () => void;
   readonly #sessionAdmission: SessionAdmissionGate;
   readonly #sessions: SessionPresenceReader;
+  readonly #sessionAccessAuthority:
+    | Pick<RuntimeHostAccessAuthority, 'activeSessionGrant'>
+    | undefined;
   readonly #uploads: ConnectionBoundChunkUploads<ArtifactUploadMetadata>;
 
   constructor(
@@ -81,11 +86,13 @@ export class HostArtifactCoordinator {
     sessionAdmission: SessionAdmissionGate,
     sessions: SessionPresenceReader,
     now: () => number = Date.now,
+    sessionAccessAuthority?: Pick<RuntimeHostAccessAuthority, 'activeSessionGrant'>,
   ) {
     this.#store = authenticateInteractiveArtifactStoreWriter(store);
     this.#requestDrain = requestDrain;
     this.#sessionAdmission = sessionAdmission;
     this.#sessions = sessions;
+    this.#sessionAccessAuthority = sessionAccessAuthority;
     this.#uploads = new ConnectionBoundChunkUploads(
       {
         maxActive: MAX_ACTIVE_ARTIFACT_UPLOADS,
@@ -310,11 +317,19 @@ export class HostArtifactCoordinator {
     return { kind: 'committed', record };
   }
 
-  async #query(input: ArtifactQueryInput): Promise<OperationOutcome<'artifact.query'>> {
+  async #query(
+    input: ArtifactQueryInput,
+    context: ConnectionContext,
+  ): Promise<OperationOutcome<'artifact.query'>> {
     try {
       if ((await this.#sessions.probeSessionRemoval(input.sessionId)).kind !== 'present') {
         return notFound('artifact.query', 'Session was not found');
       }
+      let sharedGrantId: string | undefined;
+      if (context.principalKind === 'session_guest') {
+        sharedGrantId = await this.#sharedArtifactGrantId(context.principal, input);
+        if (!sharedGrantId) return notFound('artifact.query', 'Artifact was not found');
+      }
       if (input.kind === 'read_text' || input.kind === 'read_binary') {
         if (input.kind === 'read_text') {
           const preview = await this.#store.readTextInSession(input.sessionId, input.artifactId, {
@@ -356,6 +371,9 @@ export class HostArtifactCoordinator {
           }
           return persistenceFailure('artifact.query', 'Artifact content is unavailable');
         }
+        if (!this.#sharedGrantRemainsActive(context.principal, input.sessionId, sharedGrantId)) {
+          return notFound('artifact.query', 'Artifact was not found');
+        }
         return querySuccess(
           encodeArtifactQueryResult({
             kind: 'chunk',
@@ -371,6 +389,9 @@ export class HostArtifactCoordinator {
 
       if (input.kind === 'get') {
         const entry = await this.#store.getInSession(input.sessionId, input.artifactId);
+        if (!this.#sharedGrantRemainsActive(context.principal, input.sessionId, sharedGrantId)) {
+          return notFound('artifact.query', 'Artifact was not found');
+        }
         return querySuccess(
           encodeArtifactQueryResult({
             kind: 'artifact',
@@ -410,6 +431,40 @@ export class HostArtifactCoordinator {
     }
   }
 
+  async #sharedArtifactGrantId(
+    principalId: string,
+    input: ArtifactQueryInput,
+  ): Promise<string | undefined> {
+    if (input.kind !== 'get' && input.kind !== 'read_chunk') return;
+    const grant = this.#sessionAccessAuthority?.activeSessionGrant(
+      principalId,
+      input.sessionId,
+      'session_observation',
+    );
+    if (!grant) return;
+    const entry = await this.#store.getInSession(input.sessionId, input.artifactId);
+    return entry.record?.status === 'live' &&
+      entry.record.source !== undefined &&
+      SHARED_ARTIFACT_SOURCES.has(entry.record.source)
+      ? grant.grantId
+      : undefined;
+  }
+
+  #sharedGrantRemainsActive(
+    principalId: string,
+    sessionId: string,
+    expectedGrantId: string | undefined,
+  ): boolean {
+    if (!expectedGrantId) return true;
+    return (
+      this.#sessionAccessAuthority?.activeSessionGrant(
+        principalId,
+        sessionId,
+        'session_observation',
+      )?.grantId === expectedGrantId
+    );
+  }
+
   async #delete(input: {
     readonly sessionId: string;
     readonly artifactId: string;
diff --git a/packages/runtime-host/src/server/connection-session.ts b/packages/runtime-host/src/server/connection-session.ts
index 4f54f122d..28b431f3d 100644
--- a/packages/runtime-host/src/server/connection-session.ts
+++ b/packages/runtime-host/src/server/connection-session.ts
@@ -222,6 +222,7 @@ export class RuntimeHostConnectionSession {
       const response = await dispatchOperation(frame, this.#options.resolveHandlers(), {
         ...this.#options.connection,
         principal: this.#options.connection.authority.principalId,
+        principalKind: this.#options.connection.authority.principalKind,
         ...(this.#options.connection.authority.credentialId
           ? { credentialId: this.#options.connection.authority.credentialId }
           : {}),
@@ -330,10 +331,11 @@ export class RuntimeHostConnectionSession {
           this.#options.connection.authority,
           'project.catalog.query',
         ),
-        sessionCatalog: hasRuntimeHostOperationGrant(
-          this.#options.connection.authority,
-          'session.catalog.query',
-        ),
+        sessionCatalog:
+          hasRuntimeHostOperationGrant(
+            this.#options.connection.authority,
+            'session.catalog.query',
+          ) && this.#options.connection.authority.principalKind !== 'session_guest',
         scheduledTask: hasRuntimeHostOperationGrant(
           this.#options.connection.authority,
           'scheduled-task.query',
diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts
index cd1bb2b91..edc2ff841 100644
--- a/packages/runtime-host/src/server/execution-composition.ts
+++ b/packages/runtime-host/src/server/execution-composition.ts
@@ -378,6 +378,9 @@ export async function createExecutionRuntimeHostComposition(
       sessionAdmission,
       acquireResidency: () => context.acquireResidency('runtime-resource'),
       requestDrain: context.requestDrain,
+      ...(context.sessionAccessAuthority
+        ? { sessionAccessAuthority: context.sessionAccessAuthority }
+        : {}),
       resolveShell: async () =>
         resolveShellPlan((await runtimePolicyStores.runtimePolicy.getSnapshot()).policy.shell),
       onProjectionChanged: (update) =>
@@ -556,6 +559,7 @@ export async function createExecutionRuntimeHostComposition(
       context.requestDrain,
       createSessionTranscriptReader({ stores, canonicalPermissionOutcomes }),
       (sessionId) => hostChanges.publishSessionCatalog(sessionId),
+      context.sessionAccessAuthority,
     );
     const continuityCoordinator = continuity;
     unsubscribeTranscriptChanges = stores.sessionStore.subscribeTranscriptChanges((sessionId) =>
@@ -1108,6 +1112,8 @@ export async function createExecutionRuntimeHostComposition(
       context.requestDrain,
       sessionAdmission,
       stores.sessionStore,
+      Date.now,
+      context.sessionAccessAuthority,
     );
     rootCoordinator = new RootTurnCoordinator(
       manager,
@@ -1270,6 +1276,9 @@ export async function createExecutionRuntimeHostComposition(
       continuity: continuityCoordinator,
       workspaceResolver,
       requestDrain: context.requestDrain,
+      ...(context.sessionAccessAuthority
+        ? { sessionAccessAuthority: context.sessionAccessAuthority }
+        : {}),
     });
     const workHubCoordination = new HostWorkHubCoordinationCoordinator({
       stateRoot: context.owner.capability.canonicalPath,
diff --git a/packages/runtime-host/src/server/host-kernel.ts b/packages/runtime-host/src/server/host-kernel.ts
index d7b4e67a5..004bf6ad6 100644
--- a/packages/runtime-host/src/server/host-kernel.ts
+++ b/packages/runtime-host/src/server/host-kernel.ts
@@ -119,6 +119,10 @@ export interface RuntimeHostCompositionContext {
   /** Irreversible fail-stop latch; normal residency still uses acquireResidency(). */
   retainUntilProcessExit(): void;
   requestDrain(): void;
+  sessionAccessAuthority?: Pick<
+    RuntimeHostAccessAuthority,
+    'activeSessionGrant' | 'activeSessionGrantForPrincipal' | 'subscribeGrantRevocations'
+  >;
   waitForResidencies?(): Promise<void>;
   waitForResidenciesExcept?(excludedLabel: string): Promise<void>;
 }
@@ -346,6 +350,9 @@ export class RuntimeHostKernel {
           acquireResidency: (label) => this.#acquireResidency(label),
           retainUntilProcessExit: () => this.#retainUntilProcessExit(),
           requestDrain: () => this.#requestDrain(),
+          ...(this.#options.accessAuthority
+            ? { sessionAccessAuthority: this.#options.accessAuthority }
+            : {}),
           waitForResidencies: () => this.#waitForResidencies(),
           waitForResidenciesExcept: (excludedLabel) =>
             this.#waitForResidenciesExcept(excludedLabel),
diff --git a/packages/runtime-host/src/server/operation-dispatcher.ts b/packages/runtime-host/src/server/operation-dispatcher.ts
index 552233e79..856afe48b 100644
--- a/packages/runtime-host/src/server/operation-dispatcher.ts
+++ b/packages/runtime-host/src/server/operation-dispatcher.ts
@@ -36,11 +36,13 @@ import { ACCESS_AUTHORITY_OPERATION_SPECS } from '../protocol/access-authority.j
 import { SESSION_COLLABORATION_OPERATION_SPECS } from '../protocol/session-collaboration.js';
 import { PEER_MESH_OPERATION_SPECS } from '../protocol/peer-mesh.js';
 import { createPeerMeshOperationHandlers } from './peer-mesh-authority.js';
+import type { RuntimeHostConnectionAuthority } from './connection-authority.js';
 
 export interface ConnectionContext {
   hostEpoch: string;
   connectionId: string;
   principal: string;
+  principalKind?: RuntimeHostConnectionAuthority['principalKind'];
   credentialId?: string;
   clientInstanceId?: string;
   acquireResidency(): OperationResidency;
diff --git a/packages/runtime-host/src/server/runtime-resource-coordinator.ts b/packages/runtime-host/src/server/runtime-resource-coordinator.ts
index 6dd125c54..95499ef4a 100644
--- a/packages/runtime-host/src/server/runtime-resource-coordinator.ts
+++ b/packages/runtime-host/src/server/runtime-resource-coordinator.ts
@@ -59,6 +59,7 @@ import type {
   ConnectionContext,
   RuntimeResourceOperationHandlerMap,
 } from './operation-dispatcher.js';
+import type { RuntimeHostAccessAuthority } from './access-authority.js';
 import { SessionAdmissionGate } from './session-admission-gate.js';
 import {
   boundedRuntimeResourceSnapshot,
@@ -99,6 +100,7 @@ export interface HostRuntimeResourceCoordinatorInput {
   readonly sessionAdmission: SessionAdmissionGate;
   readonly acquireResidency: () => RuntimeHostResidency;
   readonly requestDrain: () => void;
+  readonly sessionAccessAuthority?: Pick<RuntimeHostAccessAuthority, 'activeSessionGrant'>;
   readonly onProjectionChanged?: (update: ShellRunUpdate) => void;
   /**
    * Fallback shell resolution for callers that do not carry a plan (e.g.
@@ -129,7 +131,7 @@ export class HostRuntimeResourceCoordinator
   implements ShellRunLauncher, RuntimeResourceReader, BackgroundTaskStopper, PtyControlWriter
 {
   readonly handlers: RuntimeResourceOperationHandlerMap = {
-    'runtime.resource.query': (input) => this.#query(input),
+    'runtime.resource.query': (input, context) => this.#query(input, context),
     'runtime.resource.start': (input) => this.#start(input),
     'runtime.resource.controller.acquire': (input, context) => this.#acquire(input, context),
     'runtime.resource.controller.control': (input, context) => this.#control(input, context),
@@ -143,6 +145,9 @@ export class HostRuntimeResourceCoordinator
   readonly #sessionAdmission: SessionAdmissionGate;
   readonly #acquireResidency: () => RuntimeHostResidency;
   readonly #requestDrain: () => void;
+  readonly #sessionAccessAuthority:
+    | Pick<RuntimeHostAccessAuthority, 'activeSessionGrant'>
+    | undefined;
   readonly #onProjectionChanged: (update: ShellRunUpdate) => void;
   readonly #resolveShell: () => Promise<ShellPlan> | ShellPlan;
   readonly #resourceQueue = new ResourceSerialQueue();
@@ -159,6 +164,7 @@ export class HostRuntimeResourceCoordinator
     this.#sessionAdmission = input.sessionAdmission;
     this.#acquireResidency = input.acquireResidency;
     this.#requestDrain = input.requestDrain;
+    this.#sessionAccessAuthority = input.sessionAccessAuthority;
     this.#onProjectionChanged = input.onProjectionChanged ?? (() => undefined);
     this.#resolveShell = input.resolveShell ?? defaultShellPlan;
   }
@@ -285,73 +291,100 @@ export class HostRuntimeResourceCoordinator
     return updates.some((update) => isActiveShellRunStatus(update.result.status));
   }
 
-  #query(input: RuntimeResourceQueryInput): Promise<OperationOutcome<'runtime.resource.query'>> {
+  async #query(
+    input: RuntimeResourceQueryInput,
+    context: ConnectionContext,
+  ): Promise<OperationOutcome<'runtime.resource.query'>> {
     if (input.kind === 'get' && !isShellRunResourceRef(input.ref)) {
-      return Promise.resolve(
-        queryFailure('invalid_request', 'Runtime Resource ref is unsupported'),
-      );
+      return queryFailure('invalid_request', 'Runtime Resource ref is unsupported');
     }
-    return this.#sessionAdmission.run(input.sessionId, async () => {
-      try {
-        await this.#sessionHeaders.readHeader(input.sessionId);
-      } catch (error) {
-        if (isSessionNotFoundError(error)) {
-          return queryFailure('not_found', 'Session was not found');
+    const guestGrantId = this.#guestObservationGrantId(context, input.sessionId);
+    if (context.principalKind === 'session_guest' && !guestGrantId) {
+      return queryFailure('not_found', 'Session was not found');
+    }
+    const outcome: OperationOutcome<'runtime.resource.query'> = await this.#sessionAdmission.run(
+      input.sessionId,
+      async () => {
+        try {
+          await this.#sessionHeaders.readHeader(input.sessionId);
+        } catch (error) {
+          if (isSessionNotFoundError(error)) {
+            return queryFailure('not_found', 'Session was not found');
+          }
+          this.#requestDrain();
+          return queryFailure('internal_failure', 'Session state is unavailable');
         }
-        this.#requestDrain();
-        return queryFailure('internal_failure', 'Session state is unavailable');
-      }
-      if (input.kind === 'get') {
+        if (input.kind === 'get') {
+          try {
+            const resource = await this.#sessions.getShellRunUpdate(input.sessionId, input.ref);
+            const visible =
+              context.principalKind !== 'session_guest' || resource?.sessionId === input.sessionId
+                ? resource
+                : null;
+            const canonical = visible ? (canonicalRuntimeResources([visible])[0] ?? null) : null;
+            return {
+              ok: true,
+              result: decodeRuntimeResourceQueryResult({
+                kind: 'resource',
+                sessionId: input.sessionId,
+                revision: runtimeResourceRevision(canonical ? [canonical] : []),
+                resource: canonical,
+              }),
+            };
+          } catch {
+            this.#requestDrain();
+            return queryFailure('internal_failure', 'Runtime Resource state is unavailable');
+          }
+        }
+        let updates: ShellRunUpdate[];
         try {
-          const resource = await this.#sessions.getShellRunUpdate(input.sessionId, input.ref);
-          const canonical = resource ? (canonicalRuntimeResources([resource])[0] ?? null) : null;
-          return {
-            ok: true,
-            result: decodeRuntimeResourceQueryResult({
-              kind: 'resource',
-              sessionId: input.sessionId,
-              revision: runtimeResourceRevision(canonical ? [canonical] : []),
-              resource: canonical,
-            }),
-          };
+          updates = await this.#sessions.listShellRunUpdates(input.sessionId);
+          if (context.principalKind === 'session_guest') {
+            updates = updates.filter((update) => update.sessionId === input.sessionId);
+          }
         } catch {
           this.#requestDrain();
           return queryFailure('internal_failure', 'Runtime Resource state is unavailable');
         }
-      }
-      let updates: ShellRunUpdate[];
-      try {
-        updates = await this.#sessions.listShellRunUpdates(input.sessionId);
-      } catch {
-        this.#requestDrain();
-        return queryFailure('internal_failure', 'Runtime Resource state is unavailable');
-      }
-      try {
-        const resources = canonicalRuntimeResources(updates);
-        const revision = runtimeResourceRevision(resources);
-        if (input.kind === 'list_continue' && input.revision !== revision) {
+        try {
+          const resources = canonicalRuntimeResources(updates);
+          const revision = runtimeResourceRevision(resources);
+          if (input.kind === 'list_continue' && input.revision !== revision) {
+            return {
+              ok: true,
+              result: { kind: 'revision_changed', expected: input.revision, actual: revision },
+            };
+          }
+          const offset = input.kind === 'list_start' ? 0 : decodeCursor(input.cursor);
+          if (
+            offset === undefined ||
+            offset > resources.length ||
+            (input.kind === 'list_continue' && offset === 0) ||
+            (input.kind === 'list_continue' && offset === resources.length)
+          ) {
+            return queryFailure('invalid_request', 'Runtime Resource cursor is invalid');
+          }
           return {
             ok: true,
-            result: { kind: 'revision_changed', expected: input.revision, actual: revision },
+            result: createRuntimeResourcePage(input.sessionId, revision, resources, offset),
           };
+        } catch {
+          return queryFailure('internal_failure', 'Runtime Resource projection is unavailable');
         }
-        const offset = input.kind === 'list_start' ? 0 : decodeCursor(input.cursor);
-        if (
-          offset === undefined ||
-          offset > resources.length ||
-          (input.kind === 'list_continue' && offset === 0) ||
-          (input.kind === 'list_continue' && offset === resources.length)
-        ) {
-          return queryFailure('invalid_request', 'Runtime Resource cursor is invalid');
-        }
-        return {
-          ok: true,
-          result: createRuntimeResourcePage(input.sessionId, revision, resources, offset),
-        };
-      } catch {
-        return queryFailure('internal_failure', 'Runtime Resource projection is unavailable');
-      }
-    });
+      },
+    );
+    return guestGrantId && this.#guestObservationGrantId(context, input.sessionId) !== guestGrantId
+      ? queryFailure('not_found', 'Session was not found')
+      : outcome;
+  }
+
+  #guestObservationGrantId(context: ConnectionContext, sessionId: string): string | undefined {
+    if (context.principalKind !== 'session_guest') return;
+    return this.#sessionAccessAuthority?.activeSessionGrant(
+      context.principal,
+      sessionId,
+      'session_observation',
+    )?.grantId;
   }
 
   async #start(
diff --git a/packages/runtime-host/src/server/session-catalog-coordinator.ts b/packages/runtime-host/src/server/session-catalog-coordinator.ts
index 10dd15752..4069522ce 100644
--- a/packages/runtime-host/src/server/session-catalog-coordinator.ts
+++ b/packages/runtime-host/src/server/session-catalog-coordinator.ts
@@ -58,6 +58,7 @@ import {
 } from '@maka/runtime/session-manager';
 import {
   decodeSessionCatalogProjection,
+  decodeSharedSessionCatalogProjection,
   SESSION_CATALOG_LIVE_RUN_STATE_SCHEMA_VERSION,
   SESSION_CATALOG_LABEL_MAX_BYTES,
   SESSION_CATALOG_LABEL_MAX_ITEMS,
@@ -70,6 +71,7 @@ import {
   type SessionCatalogItem,
   type SessionCatalogLiveRunState,
   type SessionCatalogProjection,
+  type SharedSessionCatalogProjection,
   type SessionCatalogQueryInput,
   type SessionCatalogQueryResult,
   type SessionCatalogRevision,
@@ -88,6 +90,7 @@ import {
   projectSessionTurnContributionForWire,
 } from '../protocol/index.js';
 import type { SessionCatalogOperationHandlerMap } from './operation-dispatcher.js';
+import type { RuntimeHostAccessAuthority } from './access-authority.js';
 import { type SessionAdmissionLease, SessionAdmissionGate } from './session-admission-gate.js';
 import type { SessionContinuityCoordinator } from './session-continuity-coordinator.js';
 import { type HostWorkspaceResolver, WorkspaceResolutionError } from './workspace-resolver.js';
@@ -154,6 +157,10 @@ export interface HostSessionCatalogCoordinatorOptions {
   readonly continuity: SessionContinuity;
   readonly workspaceResolver: HostWorkspaceResolver;
   readonly requestDrain: () => void;
+  readonly sessionAccessAuthority?: Pick<
+    RuntimeHostAccessAuthority,
+    'activeSessionGrantForPrincipal'
+  >;
 }
 
 interface ResolvedSessionModel {
@@ -165,6 +172,7 @@ interface ResolvedSessionModel {
 /** Host-owned Session catalog, creation, and configuration authority. */
 export class HostSessionCatalogCoordinator {
   readonly handlers: SessionCatalogOperationHandlerMap = {
+    'session.shared.query': (_input, context) => this.#querySharedSession(context.principal),
     'session.catalog.query': (input) => this.#query(input),
     'session.create': (input) => this.#create(input),
     'session.metadata.update': (input) => this.#updateMetadata(input),
@@ -183,6 +191,9 @@ export class HostSessionCatalogCoordinator {
   readonly #continuity: SessionContinuity;
   readonly #workspaceResolver: HostWorkspaceResolver;
   readonly #requestDrain: () => void;
+  readonly #sessionAccessAuthority:
+    | Pick<RuntimeHostAccessAuthority, 'activeSessionGrantForPrincipal'>
+    | undefined;
 
   constructor(options: HostSessionCatalogCoordinatorOptions) {
     this.#stores = options.stores;
@@ -192,6 +203,7 @@ export class HostSessionCatalogCoordinator {
     this.#continuity = options.continuity;
     this.#workspaceResolver = options.workspaceResolver;
     this.#requestDrain = options.requestDrain;
+    this.#sessionAccessAuthority = options.sessionAccessAuthority;
   }
 
   async resolveExternalSessionImportTarget(): Promise<Omit<CreateSessionInput, 'cwd' | 'name'>> {
@@ -286,6 +298,48 @@ export class HostSessionCatalogCoordinator {
     }
   }
 
+  async #querySharedSession(
+    principalId: string,
+  ): Promise<OperationOutcome<'session.shared.query'>> {
+    if (!this.#sessionAccessAuthority) {
+      return {
+        ok: false,
+        error: { code: 'operation_unavailable', message: 'Session sharing is unavailable' },
+      };
+    }
+    const grant = this.#sessionAccessAuthority.activeSessionGrantForPrincipal(
+      principalId,
+      'session_observation',
+    );
+    if (!grant) return { ok: true, result: { session: null } };
+    try {
+      const record = await this.#readCatalogRecordIfPresent(grant.sessionId);
+      const currentGrant = this.#sessionAccessAuthority.activeSessionGrantForPrincipal(
+        principalId,
+        'session_observation',
+      );
+      if (currentGrant?.grantId !== grant.grantId) {
+        return { ok: true, result: { session: null } };
+      }
+      return {
+        ok: true,
+        result: {
+          session: record
+            ? projectSharedSessionCatalogRecord(
+                record,
+                projectCatalogLiveRunState(this.#manager.runningTurnIds(record.header.id)),
+              )
+            : null,
+        },
+      };
+    } catch {
+      return {
+        ok: false,
+        error: { code: 'persistence_failed', message: 'Shared Session catalog is unavailable' },
+      };
+    }
+  }
+
   #projectCatalogQueryRecord(record: SessionCatalogRecord): SessionCatalogItem {
     return projectSessionCatalogRecord(
       record,
@@ -1107,6 +1161,30 @@ export function projectSessionCatalogRecord(
   }
 }
 
+function projectSharedSessionCatalogRecord(
+  record: SessionCatalogRecord,
+  liveRunState?: SessionCatalogLiveRunState,
+): SharedSessionCatalogProjection {
+  const { header, summary } = record;
+  const shared: SharedSessionCatalogProjection = {
+    kind: 'shared_session',
+    id: header.id,
+    revision: record.revision,
+    createdAt: header.createdAt,
+    activityAt: record.activityAt,
+    name: header.name,
+    ...(summary.lastMessageAt === undefined ? {} : { lastMessageAt: summary.lastMessageAt }),
+    ...(summary.lastMessagePreview === undefined
+      ? {}
+      : { lastMessagePreview: summary.lastMessagePreview }),
+    status: header.status,
+    ...(liveRunState === undefined ? {} : { liveRunState }),
+    ...(header.blockedReason === undefined ? {} : { blockedReason: header.blockedReason }),
+    ...(header.statusUpdatedAt === undefined ? {} : { statusUpdatedAt: header.statusUpdatedAt }),
+  };
+  return decodeSharedSessionCatalogProjection(shared);
+}
+
 function projectCatalogLiveRunState(
   runningTurnIds: readonly string[],
 ): SessionCatalogLiveRunState | undefined {
diff --git a/packages/runtime-host/src/server/session-continuity-coordinator.ts b/packages/runtime-host/src/server/session-continuity-coordinator.ts
index cb3b9fc81..2596e1a8a 100644
--- a/packages/runtime-host/src/server/session-continuity-coordinator.ts
+++ b/packages/runtime-host/src/server/session-continuity-coordinator.ts
@@ -54,7 +54,11 @@ import {
   type TurnProviderRetry,
   type TurnSnapshot,
 } from '../protocol/index.js';
-import type { SessionContinuityOperationHandlerMap } from './operation-dispatcher.js';
+import type {
+  ConnectionContext,
+  SessionContinuityOperationHandlerMap,
+} from './operation-dispatcher.js';
+import type { RuntimeHostAccessAuthority } from './access-authority.js';
 import { type SessionAdmissionLease, SessionAdmissionGate } from './session-admission-gate.js';
 import {
   type CanonicalSessionProjection,
@@ -77,6 +81,7 @@ import {
   ACTIVE_TRANSCRIPT_OVERLAY_MAX_BYTES,
   type SessionTranscriptReader,
 } from './session-transcript-reader.js';
+import { projectSharedSessionMessageContent } from './shared-session-transcript.js';
 
 const MAX_CONNECTION_SUBSCRIPTIONS = 16;
 const MAX_SUBSCRIBER_QUEUED_FRAMES = 32;
@@ -152,6 +157,8 @@ interface QueuedSubscriptionFrame {
 
 interface Subscriber {
   connectionId: string;
+  principalId: string;
+  principalKind: NonNullable<ConnectionContext['principalKind']>;
   sessionId: string;
   subscriptionId: string;
   sink: SessionContinuityFrameSink;
@@ -230,7 +237,7 @@ interface PendingSessionDomainChanges {
 export class SessionContinuityCoordinator implements SessionContinuityService {
   readonly handlers: SessionContinuityOperationHandlerMap = {
     'subscription.open': async (input, context) => {
-      const result = await this.#open(context.connectionId, input);
+      const result = await this.#open(context, input);
       return result.ok
         ? { ok: true, result: result.value }
         : { ok: false, error: { code: result.code, message: result.message } };
@@ -277,6 +284,10 @@ export class SessionContinuityCoordinator implements SessionContinuityService {
   #closed = false;
   #preparingTranscriptOverlayBytes = 0;
   #retainedTranscriptOverlayBytes = 0;
+  readonly #sessionAccessAuthority:
+    | Pick<RuntimeHostAccessAuthority, 'activeSessionGrant' | 'subscribeGrantRevocations'>
+    | undefined;
+  readonly #unsubscribeGrantRevocations: (() => void) | undefined;
 
   constructor(
     hostEpoch: string,
@@ -285,10 +296,28 @@ export class SessionContinuityCoordinator implements SessionContinuityService {
     private readonly onPublicationFailure: (error: unknown) => void = () => undefined,
     transcriptReader?: SessionTranscriptReader,
     private readonly onCatalogChanged: (sessionId: string) => void = () => undefined,
+    sessionAccessAuthority?: Pick<
+      RuntimeHostAccessAuthority,
+      'activeSessionGrant' | 'subscribeGrantRevocations'
+    >,
   ) {
     this.#hostEpoch = hostEpoch;
     this.#readCanonical = readCanonical;
     this.#transcriptReader = transcriptReader;
+    this.#sessionAccessAuthority = sessionAccessAuthority;
+    this.#unsubscribeGrantRevocations = sessionAccessAuthority?.subscribeGrantRevocations(
+      (grant) => {
+        if (grant.kind !== 'session_observation') return;
+        for (const subscriber of this.#subscriptions.values()) {
+          if (
+            subscriber.principalId === grant.principalId &&
+            subscriber.sessionId === grant.sessionId
+          ) {
+            this.#closeSubscriber(subscriber, 'access_revoked');
+          }
+        }
+      },
+    );
   }
 
   attachConnection(
@@ -394,6 +423,7 @@ export class SessionContinuityCoordinator implements SessionContinuityService {
         const state = this.#sessions.get(event.rootSessionId);
         if (!state) return;
         for (const subscriber of state.subscribers.values()) {
+          if (subscriber.principalKind === 'session_guest') continue;
           const frame: AgentGraphChangedFrame = {
             kind: 'subscription.agent_graph_changed',
             hostEpoch: this.#hostEpoch,
@@ -518,12 +548,24 @@ export class SessionContinuityCoordinator implements SessionContinuityService {
         }
         for (const change of frames) {
           for (const subscriber of state.subscribers.values()) {
+            const projected =
+              change.domain === 'runtime_resource' && subscriber.principalKind === 'session_guest'
+                ? {
+                    ...change,
+                    resources: change.resources.filter(
+                      (resource) => resource.sourceSessionId === subscriber.sessionId,
+                    ),
+                  }
+                : change;
+            if (projected.domain === 'runtime_resource' && projected.resources.length === 0) {
+              continue;
+            }
             const frame: SessionDomainChangedFrame = {
               kind: 'subscription.session_domain_changed',
               hostEpoch: this.#hostEpoch,
               subscriptionId: subscriber.subscriptionId,
               sequence: subscriber.nextSequence,
-              ...change,
+              ...projected,
             };
             this.#enqueue(subscriber, frame);
           }
@@ -739,7 +781,6 @@ export class SessionContinuityCoordinator implements SessionContinuityService {
       } else if (event.type === 'tool_result') {
         state.toolResultPreviews.delete(event.toolUseId);
       }
-      const projected = projectSessionEvent(event);
       for (const subscriber of state.subscribers.values()) {
         const frame: SessionEventFrame = {
           kind: 'subscription.session_event',
@@ -748,7 +789,11 @@ export class SessionContinuityCoordinator implements SessionContinuityService {
           sequence: subscriber.nextSequence,
           sessionId,
           runId,
-          event: projected,
+          event: projectSessionEvent(
+            event,
+            sessionId,
+            subscriber.principalKind === 'session_guest',
+          ),
         };
         this.#enqueue(subscriber, frame);
       }
@@ -779,6 +824,7 @@ export class SessionContinuityCoordinator implements SessionContinuityService {
   close(): void {
     if (this.#closed) return;
     this.#closed = true;
+    this.#unsubscribeGrantRevocations?.();
     this.#cancelTranscriptOverlayPreparationWaiters();
     for (const connectionId of [...this.#connections.keys()]) this.#closeConnection(connectionId);
     for (const state of this.#sessions.values()) this.#invalidateTranscriptOverlay(state);
@@ -790,7 +836,7 @@ export class SessionContinuityCoordinator implements SessionContinuityService {
   }
 
   async #open(
-    connectionId: string,
+    context: ConnectionContext,
     input: SubscriptionOpenInput,
   ): Promise<
     | { ok: true; value: SubscriptionOpenResult }
@@ -800,9 +846,14 @@ export class SessionContinuityCoordinator implements SessionContinuityService {
         message: string;
       }
   > {
+    const connectionId = context.connectionId;
+    const identity = connectionIdentity(context);
     const sessionId = input.sessionId;
     const connection = this.#connections.get(connectionId);
     if (!connection) throw new Error('Runtime Host connection is not attached to continuity');
+    if (!this.#canObserve(identity, sessionId)) {
+      return { ok: false, code: 'not_found', message: 'Session was not found' };
+    }
     if (
       connection.subscriptionIds.size + connection.pendingOpenCount >=
       MAX_CONNECTION_SUBSCRIPTIONS
@@ -878,6 +929,7 @@ export class SessionContinuityCoordinator implements SessionContinuityService {
                       throw new Error('Runtime Host connection closed during subscription open');
                     }),
                   ]);
+                  const snapshot = projectSessionSnapshot(committed.value, identity.principalKind);
                   const created = await createSessionTranscriptBootstrap({
                     reader: this.#transcriptReader,
                     sessionId,
@@ -887,11 +939,12 @@ export class SessionContinuityCoordinator implements SessionContinuityService {
                     activeAssistantStreams: committed.state.assistantStreams.values(),
                     maxBytes: input.transcript.maxBytes,
                     preparedOverlayMessages: retainedTranscriptOverlay.messages,
+                    projection: identity.principalKind === 'session_guest' ? 'shared' : 'owner',
                     maxEncodedBytes: subscriptionOpenTranscriptBudget({
                       hostEpoch: this.#hostEpoch,
                       subscriptionId,
                       nextSequence: 1,
-                      snapshot: committed.value,
+                      snapshot,
                       activeAssistantStreams,
                       transcript: null,
                     }),
@@ -921,7 +974,7 @@ export class SessionContinuityCoordinator implements SessionContinuityService {
                 hostEpoch: this.#hostEpoch,
                 subscriptionId,
                 nextSequence: 1,
-                snapshot: committed.value,
+                snapshot: projectSessionSnapshot(committed.value, identity.principalKind),
                 activeAssistantStreams,
                 transcript: transcriptBootstrap,
               };
@@ -935,8 +988,17 @@ export class SessionContinuityCoordinator implements SessionContinuityService {
                   message: 'Session subscription state exceeds the transport limit',
                 };
               }
+              if (!this.#canObserve(identity, sessionId)) {
+                return {
+                  ok: false as const,
+                  code: 'not_found' as const,
+                  message: 'Session was not found',
+                };
+              }
               const subscriber: Subscriber = {
                 connectionId,
+                principalId: identity.principalId,
+                principalKind: identity.principalKind,
                 sessionId,
                 subscriptionId,
                 sink: connection.sink,
@@ -973,7 +1035,11 @@ export class SessionContinuityCoordinator implements SessionContinuityService {
                     sequence: subscriber.nextSequence,
                     sessionId,
                     runId: rootTurn.runId,
-                    event: projectSessionEvent(preview),
+                    event: projectSessionEvent(
+                      preview,
+                      sessionId,
+                      subscriber.principalKind === 'session_guest',
+                    ),
                   };
                   this.#enqueue(subscriber, frame);
                 }
@@ -1041,9 +1107,18 @@ export class SessionContinuityCoordinator implements SessionContinuityService {
         error: { code: 'operation_unavailable', message: 'Session transcript is unavailable' },
       };
     }
+    const connection = this.#connections.get(connectionId);
+    if (!connection || !this.#canObserve(subscriber, subscriber.sessionId)) {
+      this.#closeSubscriber(subscriber, 'access_revoked');
+      return transcriptSubscriptionNotFound();
+    }
     const transcript = subscriber.transcript;
     return this.sessionAdmission.run(subscriber.sessionId, async () => {
-      if (this.#ownedSubscriber(connectionId, input.subscriptionId) !== subscriber) {
+      if (
+        this.#ownedSubscriber(connectionId, input.subscriptionId) !== subscriber ||
+        this.#connections.get(connectionId) !== connection ||
+        !this.#canObserve(subscriber, subscriber.sessionId)
+      ) {
         return transcriptSubscriptionNotFound();
       }
       try {
@@ -1052,7 +1127,11 @@ export class SessionContinuityCoordinator implements SessionContinuityService {
           state: transcript,
           request: input,
         });
-        if (this.#ownedSubscriber(connectionId, input.subscriptionId) !== subscriber) {
+        if (
+          this.#ownedSubscriber(connectionId, input.subscriptionId) !== subscriber ||
+          this.#connections.get(connectionId) !== connection ||
+          !this.#canObserve(subscriber, subscriber.sessionId)
+        ) {
           return transcriptSubscriptionNotFound();
         }
         return { ok: true, result: page };
@@ -1426,6 +1505,10 @@ export class SessionContinuityCoordinator implements SessionContinuityService {
   }
 
   #evictSlowSubscriber(subscriber: Subscriber): void {
+    this.#closeSubscriber(subscriber, 'slow_consumer');
+  }
+
+  #closeSubscriber(subscriber: Subscriber, reason: 'slow_consumer' | 'access_revoked'): void {
     if (subscriber.phase !== 'open') return;
     subscriber.phase = 'closing';
     const inFlight = subscriber.pumping ? subscriber.queue[0] : undefined;
@@ -1437,7 +1520,7 @@ export class SessionContinuityCoordinator implements SessionContinuityService {
       hostEpoch: this.#hostEpoch,
       subscriptionId: subscriber.subscriptionId,
       sequence: subscriber.nextSequence,
-      reason: 'slow_consumer',
+      reason,
     };
     subscriber.nextSequence += 1;
     subscriber.terminalQueued = true;
@@ -1451,6 +1534,20 @@ export class SessionContinuityCoordinator implements SessionContinuityService {
     if (subscriber.activated) this.#pump(subscriber);
   }
 
+  #canObserve(
+    identity: { readonly principalId: string; readonly principalKind: Subscriber['principalKind'] },
+    sessionId: string,
+  ): boolean {
+    return (
+      identity.principalKind !== 'session_guest' ||
+      this.#sessionAccessAuthority?.activeSessionGrant(
+        identity.principalId,
+        sessionId,
+        'session_observation',
+      ) !== undefined
+    );
+  }
+
   #enqueueAssistantDelta(
     subscriber: Subscriber,
     sessionId: string,
@@ -1741,7 +1838,7 @@ export class SessionContinuityCoordinator implements SessionContinuityService {
         hostEpoch: this.#hostEpoch,
         subscriptionId: subscriber.subscriptionId,
         sequence: subscriber.nextSequence,
-        snapshot,
+        snapshot: projectSessionSnapshot(snapshot, subscriber.principalKind),
       });
     }
   }
@@ -1925,6 +2022,37 @@ function jsonStringContentBytes(value: string): number {
   return Buffer.byteLength(encoded.slice(1, -1), 'utf8');
 }
 
+function connectionIdentity(context: ConnectionContext): {
+  readonly principalId: string;
+  readonly principalKind: Subscriber['principalKind'];
+} {
+  if (!context.principalKind) {
+    throw new Error('Runtime Host connection has no authenticated principal kind');
+  }
+  return { principalId: context.principal, principalKind: context.principalKind };
+}
+
+function projectSessionSnapshot(
+  snapshot: SessionContinuitySnapshot,
+  principalKind: Subscriber['principalKind'],
+): SessionContinuitySnapshot {
+  if (principalKind !== 'session_guest') return snapshot;
+  const projectEntry = <T extends { readonly content: import('@maka/core/events').MessageContent }>(
+    entry: T,
+  ): T => ({
+    ...entry,
+    content: projectSharedSessionMessageContent(entry.content, snapshot.session.sessionId),
+  });
+  return {
+    ...snapshot,
+    queue: {
+      ...snapshot.queue,
+      steering: snapshot.queue.steering.map(projectEntry),
+      followup: snapshot.queue.followup.map(projectEntry),
+    },
+  };
+}
+
 function projectSessionEvent(
   event: Exclude<
     RuntimeSessionForwardedEvent,
@@ -1937,6 +2065,8 @@ function projectSessionEvent(
         | 'provider_retry';
     }
   >,
+  sessionId: string,
+  shared = false,
 ): SessionToolEvent | SessionSteeringEvent {
   if (event.type === 'steering_message') {
     // The durable steering echo: forwarded verbatim so subscribers render the
@@ -1948,7 +2078,9 @@ function projectSessionEvent(
       turnId: event.turnId,
       ts: event.ts,
       messageId: event.messageId,
-      content: structuredClone(event.content),
+      content: shared
+        ? projectSharedSessionMessageContent(event.content, sessionId)
+        : structuredClone(event.content),
     };
   }
   const identity = {
diff --git a/packages/runtime-host/src/server/session-transcript-pager.ts b/packages/runtime-host/src/server/session-transcript-pager.ts
index 01f5618bc..5c7dba3e0 100644
--- a/packages/runtime-host/src/server/session-transcript-pager.ts
+++ b/packages/runtime-host/src/server/session-transcript-pager.ts
@@ -34,6 +34,9 @@ import {
   ACTIVE_TRANSCRIPT_OVERLAY_MAX_MESSAGES,
   type SessionTranscriptReader,
 } from './session-transcript-reader.js';
+import { projectSharedSessionTranscriptMessage } from './shared-session-transcript.js';
+
+type SessionTranscriptProjection = 'owner' | 'shared';
 
 interface TranscriptCursorState {
   readonly version: 1;
@@ -53,6 +56,7 @@ export interface SubscriberTranscriptState {
   overlayMessages: readonly Buffer[] | undefined;
   readonly cursorSecret: Buffer;
   durableThroughSequence: number | null;
+  readonly projection: SessionTranscriptProjection;
 }
 
 export interface ActiveTranscriptAssistantStream {
@@ -78,9 +82,17 @@ export async function createSessionTranscriptBootstrap(input: {
   maxBytes: number;
   maxEncodedBytes?: number;
   preparedOverlayMessages?: readonly Buffer[];
+  projection: SessionTranscriptProjection;
 }): Promise<{ bootstrap: SessionTranscriptBootstrap; state: SubscriberTranscriptState }> {
-  const overlayMessages =
+  const projection = input.projection;
+  const preparedOverlayMessages =
     input.preparedOverlayMessages ?? (await prepareSessionTranscriptOverlay(input));
+  const overlayMessages =
+    projection === 'shared'
+      ? preparedOverlayMessages.flatMap((message) =>
+          projectEncodedSharedMessage(message, input.sessionId),
+        )
+      : preparedOverlayMessages;
   const cursorSecret = randomBytes(32);
   let rawBudget = input.maxBytes;
   for (;;) {
@@ -93,12 +105,16 @@ export async function createSessionTranscriptBootstrap(input: {
       overlayBudget,
     );
     const durableBudget = rawBudget - selectedOverlay.rawBytes;
-    const durableStorage = await input.reader.readDurablePage(input.sessionId, {
+    const durableRequest = {
       direction: 'older',
       throughSequence: input.throughSequence,
       maxBytes: durableBudget,
       maxMessages: SESSION_TRANSCRIPT_PAGE_MAX_MESSAGES,
-    });
+    } as const;
+    const durableStorage =
+      projection === 'shared'
+        ? await readSharedDurablePage(input.reader, input.sessionId, durableRequest)
+        : await input.reader.readDurablePage(input.sessionId, durableRequest);
     if (durableStorage.throughSequence !== input.throughSequence) {
       throw new Error('Session transcript durable watermark changed during bootstrap');
     }
@@ -109,9 +125,11 @@ export async function createSessionTranscriptBootstrap(input: {
       durableThroughSequence: input.throughSequence,
       overlayMessages,
       cursorSecret,
+      projection,
     };
     const bootstrap: SessionTranscriptBootstrap = {
       throughSequence: input.throughSequence,
+      durableCoverage: projection === 'shared' ? 'projected' : 'complete',
       overlayMessageCount: overlayMessages.length,
       durable: pageFromSelection(state, 'durable', 'older', storageSelection(durableStorage)),
       overlay: pageFromSelection(state, 'overlay', 'older', selectedOverlay),
@@ -193,14 +211,18 @@ export async function readSessionTranscriptPage(input: {
     );
   }
   if (request.throughSequence === null) return emptyPage(state, request);
-  const storage = await input.reader.readDurablePage(state.sessionId, {
+  const durableRequest = {
     direction: request.direction,
     throughSequence: request.throughSequence,
     position: position.position,
     ...(position.byteOffset === null ? {} : { byteOffset: position.byteOffset }),
     maxBytes: request.maxBytes,
     maxMessages: SESSION_TRANSCRIPT_PAGE_MAX_MESSAGES,
-  });
+  } as const;
+  const storage =
+    state.projection === 'shared'
+      ? await readSharedDurablePage(input.reader, state.sessionId, durableRequest)
+      : await input.reader.readDurablePage(state.sessionId, durableRequest);
   return pageFromSelection(
     state,
     'durable',
@@ -210,6 +232,82 @@ export async function readSessionTranscriptPage(input: {
   );
 }
 
+async function readSharedDurablePage(
+  reader: SessionTranscriptReader,
+  sessionId: string,
+  request: Parameters<SessionTranscriptReader['readDurablePage']>[1],
+): ReturnType<SessionTranscriptReader['readDurablePage']> {
+  const position =
+    request.position ??
+    (request.direction === 'older' ? (request.throughSequence ?? undefined) : 0);
+  const scanned = await reader.readDurableRecords(sessionId, {
+    direction: request.direction,
+    ...(request.throughSequence === undefined ? {} : { throughSequence: request.throughSequence }),
+    ...(position === undefined ? {} : { position }),
+    maxStoredBytes: ACTIVE_TRANSCRIPT_OVERLAY_MAX_BYTES,
+    maxMessages: SESSION_TRANSCRIPT_PAGE_MAX_MESSAGES,
+  });
+  const fragments: Awaited<
+    ReturnType<SessionTranscriptReader['readDurablePage']>
+  >['fragments'][number][] = [];
+  let rawBytes = 0;
+  let next: { position: number; byteOffset: number | null } | null = null;
+  let recordIndex = 0;
+  for (; recordIndex < scanned.records.length; recordIndex += 1) {
+    const record = scanned.records[recordIndex]!;
+    const projected = projectSharedSessionTranscriptMessage(record.message, sessionId);
+    if (!projected) continue;
+    const bytes = Buffer.from(JSON.stringify(projected), 'utf8');
+    const continuationOffset =
+      record.sequence === position && request.byteOffset !== undefined ? request.byteOffset : null;
+    const selected = selectBuffer(
+      bytes,
+      request.direction,
+      continuationOffset,
+      request.maxBytes - rawBytes,
+    );
+    if (!selected) break;
+    fragments.push({
+      sequence: record.sequence,
+      byteOffset: selected.byteOffset,
+      totalBytes: bytes.byteLength,
+      payloadDigest: null,
+      data: selected.data,
+    });
+    rawBytes += selected.data.byteLength;
+    if (!selected.complete) {
+      next = { position: record.sequence, byteOffset: selected.nextOffset };
+      break;
+    }
+    if (fragments.length === request.maxMessages || rawBytes === request.maxBytes) {
+      recordIndex += 1;
+      break;
+    }
+  }
+  if (!next) {
+    next =
+      recordIndex < scanned.records.length
+        ? { position: scanned.records[recordIndex]!.sequence, byteOffset: null }
+        : scanned.nextPosition === null
+          ? null
+          : { position: scanned.nextPosition, byteOffset: null };
+  }
+  return {
+    throughSequence: scanned.throughSequence,
+    fragments,
+    rawBytes,
+    next,
+  };
+}
+
+function projectEncodedSharedMessage(bytes: Buffer, sessionId: string): Buffer[] {
+  const projected = projectSharedSessionTranscriptMessage(
+    JSON.parse(bytes.toString('utf8')),
+    sessionId,
+  );
+  return projected ? [Buffer.from(JSON.stringify(projected), 'utf8')] : [];
+}
+
 export function updateSubscriberTranscriptHighWater(
   state: SubscriberTranscriptState,
   throughSequence: number | null,
diff --git a/packages/runtime-host/src/server/session-transcript-reader.ts b/packages/runtime-host/src/server/session-transcript-reader.ts
index 311cf5dc9..442d3b885 100644
--- a/packages/runtime-host/src/server/session-transcript-reader.ts
+++ b/packages/runtime-host/src/server/session-transcript-reader.ts
@@ -32,6 +32,8 @@ import type {
   ExecutionStoresWriter,
   SessionTranscriptMessageLookupRequest,
   SessionTranscriptPageRequest,
+  SessionTranscriptRecordScanPage,
+  SessionTranscriptRecordScanRequest,
   SessionTranscriptStoragePage,
 } from '@maka/storage/execution-stores';
 import { SESSION_TRANSCRIPT_OVERLAY_MAX_MESSAGES, type TurnSnapshot } from '../protocol/index.js';
@@ -51,6 +53,8 @@ export function createSessionTranscriptReader(input: {
       input.stores.sessionStore.readTranscriptHighWaterSnapshot(sessionId),
     readDurablePage: (sessionId, request) =>
       input.stores.sessionStore.readTranscriptPageSnapshot(sessionId, request),
+    readDurableRecords: (sessionId, request) =>
+      input.stores.sessionStore.readTranscriptRecordsSnapshot(sessionId, request),
     readDurableMessagesById: (sessionId, request) =>
       input.stores.sessionStore.readTranscriptMessagesSnapshot(sessionId, request),
     readActiveOverlay: async (sessionId, rootTurn) => {
@@ -81,6 +85,10 @@ export interface SessionTranscriptReader {
     sessionId: string,
     request: SessionTranscriptPageRequest,
   ): Promise<SessionTranscriptStoragePage>;
+  readDurableRecords(
+    sessionId: string,
+    request: SessionTranscriptRecordScanRequest,
+  ): Promise<SessionTranscriptRecordScanPage>;
   readDurableMessagesById(
     sessionId: string,
     request: SessionTranscriptMessageLookupRequest,
diff --git a/packages/runtime-host/src/server/shared-session-transcript.ts b/packages/runtime-host/src/server/shared-session-transcript.ts
new file mode 100644
index 000000000..5c59dd8b0
--- /dev/null
+++ b/packages/runtime-host/src/server/shared-session-transcript.ts
@@ -0,0 +1,182 @@
+/*
+ * 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 { AttachmentRef, MessageContent } from '@maka/core/events';
+import { projectToolActivityArgs } from '@maka/core/tool-activity-args';
+import {
+  isUserVisibleSessionSystemNote,
+  type AssistantThinking,
+  type StoredMessage,
+  userFacingText,
+} from '@maka/core/session';
+
+/** Removes model-only composition while preserving what the user actually sent. */
+export function projectSharedSessionMessageContent(
+  content: MessageContent,
+  sessionId: string,
+): MessageContent {
+  const attachments = content.attachments?.filter((attachment) =>
+    isSharedSessionAttachment(attachment, sessionId),
+  );
+  return {
+    text: userFacingText(content),
+    ...(attachments === undefined ? {} : { attachments: structuredClone(attachments) }),
+    ...(content.quotes === undefined ? {} : { quotes: structuredClone(content.quotes) }),
+    ...(content.inlineReferences === undefined
+      ? {}
+      : { inlineReferences: structuredClone(content.inlineReferences) }),
+  };
+}
+
+/** Projects the canonical transcript fields that the conversation UI can present. */
+export function projectSharedSessionTranscriptMessage(
+  message: StoredMessage,
+  sessionId: string,
+): StoredMessage | null {
+  switch (message.type) {
+    case 'user': {
+      return {
+        ...projectSharedSessionMessageContent(message, sessionId),
+        type: message.type,
+        id: message.id,
+        turnId: message.turnId,
+        ts: message.ts,
+        ...(message.steeringEventId === undefined
+          ? {}
+          : { steeringEventId: message.steeringEventId }),
+        ...(message.origin === undefined ? {} : { origin: message.origin }),
+      };
+    }
+    case 'assistant':
+      return {
+        type: message.type,
+        id: message.id,
+        turnId: message.turnId,
+        ts: message.ts,
+        text: message.text,
+        modelId: message.modelId,
+        ...(message.thinking === undefined ? {} : { thinking: projectThinking(message.thinking) }),
+        ...(message.contentOrder === undefined ? {} : { contentOrder: message.contentOrder }),
+      };
+    case 'tool_call':
+      return {
+        type: message.type,
+        id: message.id,
+        turnId: message.turnId,
+        ts: message.ts,
+        toolName: message.toolName,
+        args: projectToolActivityArgs(message.toolName, message.args),
+        ...(message.activityKind === undefined ? {} : { activityKind: message.activityKind }),
+        ...(message.displayName === undefined ? {} : { displayName: message.displayName }),
+        ...(message.intent === undefined ? {} : { intent: message.intent }),
+        ...(message.stepId === undefined ? {} : { stepId: message.stepId }),
+        ...(message.origin === undefined ? {} : { origin: message.origin }),
+        ...(message.modelVisibility === undefined
+          ? {}
+          : { modelVisibility: message.modelVisibility }),
+        ...(message.parentToolCallId === undefined
+          ? {}
+          : { parentToolCallId: message.parentToolCallId }),
+        ...(message.parentOperationId === undefined
+          ? {}
+          : { parentOperationId: message.parentOperationId }),
+      };
+    case 'tool_result':
+      return {
+        type: message.type,
+        id: message.id,
+        turnId: message.turnId,
+        ts: message.ts,
+        toolUseId: message.toolUseId,
+        isError: message.isError,
+        content: message.content,
+        ...(message.durationMs === undefined ? {} : { durationMs: message.durationMs }),
+        ...(message.origin === undefined ? {} : { origin: message.origin }),
+        ...(message.modelVisibility === undefined
+          ? {}
+          : { modelVisibility: message.modelVisibility }),
+        ...(message.parentToolCallId === undefined
+          ? {}
+          : { parentToolCallId: message.parentToolCallId }),
+        ...(message.parentOperationId === undefined
+          ? {}
+          : { parentOperationId: message.parentOperationId }),
+      };
+    case 'turn_state':
+      return {
+        type: message.type,
+        id: message.id,
+        turnId: message.turnId,
+        ts: message.ts,
+        status: message.status,
+        ...(message.parentTurnId === undefined ? {} : { parentTurnId: message.parentTurnId }),
+        ...(message.retriedFromTurnId === undefined
+          ? {}
+          : { retriedFromTurnId: message.retriedFromTurnId }),
+        ...(message.regeneratedFromTurnId === undefined
+          ? {}
+          : { regeneratedFromTurnId: message.regeneratedFromTurnId }),
+        ...(message.branchOfTurnId === undefined ? {} : { branchOfTurnId: message.branchOfTurnId }),
+        ...(message.abortedAt === undefined ? {} : { abortedAt: message.abortedAt }),
+        ...(message.abortSource === undefined ? {} : { abortSource: message.abortSource }),
+        ...(message.errorClass === undefined ? {} : { errorClass: message.errorClass }),
+        partialOutputRetained: message.partialOutputRetained,
+      };
+    case 'token_usage':
+      return {
+        type: message.type,
+        id: message.id,
+        turnId: message.turnId,
+        ts: message.ts,
+        input: message.input,
+        output: message.output,
+        ...(message.cacheMissInput === undefined ? {} : { cacheMissInput: message.cacheMissInput }),
+        ...(message.cacheRead === undefined ? {} : { cacheRead: message.cacheRead }),
+        ...(message.cacheCreation === undefined ? {} : { cacheCreation: message.cacheCreation }),
+        ...(message.reasoning === undefined ? {} : { reasoning: message.reasoning }),
+        ...(message.costUsd === undefined ? {} : { costUsd: message.costUsd }),
+      };
+    case 'system_note':
+      return isUserVisibleSessionSystemNote(message.kind)
+        ? {
+            type: message.type,
+            id: message.id,
+            ...(message.turnId === undefined ? {} : { turnId: message.turnId }),
+            ts: message.ts,
+            kind: message.kind,
+          }
+        : null;
+    case 'permission_decision':
+    case 'workhub_coordination':
+      return null;
+  }
+}
+
+function isSharedSessionAttachment(attachment: AttachmentRef, sessionId: string): boolean {
+  return attachment.ref.kind === 'session_file' && attachment.ref.sessionId === sessionId;
+}
+
+function projectThinking(thinking: AssistantThinking): AssistantThinking {
+  return {
+    text: thinking.text,
+    ...(thinking.parts === undefined
+      ? {}
+      : { parts: thinking.parts.map((part) => ({ text: part.text })) }),
+  };
+}
diff --git a/packages/storage/src/__tests__/session-store.test.ts b/packages/storage/src/__tests__/session-store.test.ts
index 39499758b..c59ea4a8f 100644
--- a/packages/storage/src/__tests__/session-store.test.ts
+++ b/packages/storage/src/__tests__/session-store.test.ts
@@ -661,6 +661,15 @@ describe('SQLite SessionStore', () => {
       );
       assert.deepEqual(tail.next, { position: 1, byteOffset: null });
 
+      const decodedTail = await store.readTranscriptRecordsSnapshot(session.id, {
+        direction: 'older',
+        maxStoredBytes: 1,
+        maxMessages: 2,
+      });
+      assert.equal(decodedTail.throughSequence, 3);
+      assert.deepEqual(decodedTail.records, [{ sequence: 3, message: messages[3] }]);
+      assert.equal(decodedTail.nextPosition, 2);
+
       await store.appendMessage(session.id, {
         type: 'user',
         id: 'message-4',
diff --git a/packages/storage/src/execution-stores.ts b/packages/storage/src/execution-stores.ts
index 047e5ca4e..f50ffee65 100644
--- a/packages/storage/src/execution-stores.ts
+++ b/packages/storage/src/execution-stores.ts
@@ -126,6 +126,8 @@ export type {
   SessionHeaderSnapshot,
   SessionTranscriptMessageLookupRequest,
   SessionTranscriptPageRequest,
+  SessionTranscriptRecordScanPage,
+  SessionTranscriptRecordScanRequest,
   SessionTranscriptStoragePage,
   SessionTranscriptStorageFragment,
 } from './session-store.js';
@@ -390,6 +392,8 @@ async function createExecutionStoresForWrite<K extends StorageRootKind, E extend
       readMessagesSnapshot: (sessionId) => run(() => sessionStore.readMessagesSnapshot(sessionId)),
       readTranscriptPageSnapshot: (sessionId, request) =>
         run(() => sessionStore.readTranscriptPageSnapshot(sessionId, request)),
+      readTranscriptRecordsSnapshot: (sessionId, request) =>
+        run(() => sessionStore.readTranscriptRecordsSnapshot(sessionId, request)),
       readTranscriptMessagesSnapshot: (sessionId, request) =>
         run(() => sessionStore.readTranscriptMessagesSnapshot(sessionId, request)),
       readTranscriptHighWaterSnapshot: (sessionId) =>
diff --git a/packages/storage/src/session-store.ts b/packages/storage/src/session-store.ts
index 5605ed6cc..55eed4d1d 100644
--- a/packages/storage/src/session-store.ts
+++ b/packages/storage/src/session-store.ts
@@ -255,6 +255,20 @@ export interface SessionTranscriptStoragePage {
   } | null;
 }
 
+export interface SessionTranscriptRecordScanRequest {
+  readonly direction: 'older' | 'newer';
+  readonly throughSequence?: number | null;
+  readonly position?: number;
+  readonly maxStoredBytes: number;
+  readonly maxMessages: number;
+}
+
+export interface SessionTranscriptRecordScanPage {
+  readonly throughSequence: number | null;
+  readonly records: readonly { readonly sequence: number; readonly message: StoredMessage }[];
+  readonly nextPosition: number | null;
+}
+
 export interface SessionTurnContribution {
   readonly turnId: string;
   readonly firstSequence: number;
@@ -331,6 +345,11 @@ export interface SessionStore {
 }
 
 export interface SessionAuthorityStore extends SessionStore, MessageAdmissionStore {
+  /** Decode a bounded ledger range for an authority-owned wire projection. */
+  readTranscriptRecordsSnapshot(
+    sessionId: string,
+    request: SessionTranscriptRecordScanRequest,
+  ): Promise<SessionTranscriptRecordScanPage>;
   /** Read a bounded set of durable messages at an inclusive transcript watermark. */
   readTranscriptMessagesSnapshot(
     sessionId: string,
@@ -891,6 +910,14 @@ class SqliteSessionStore implements SessionAuthorityStore {
     return this.metadata.readTranscriptMessages(sessionId, request);
   }
 
+  async readTranscriptRecordsSnapshot(
+    sessionId: string,
+    request: SessionTranscriptRecordScanRequest,
+  ): Promise<SessionTranscriptRecordScanPage> {
+    await this.ensureReady();
+    return this.metadata.readTranscriptRecords(sessionId, request);
+  }
+
   async readTranscriptHighWaterSnapshot(sessionId: string): Promise<number | null> {
     await this.ensureReady();
     return this.metadata.readTranscriptHighWater(sessionId);
diff --git a/packages/storage/src/sqlite-session-metadata-store.ts b/packages/storage/src/sqlite-session-metadata-store.ts
index 07d238eb1..bd707e0a7 100644
--- a/packages/storage/src/sqlite-session-metadata-store.ts
+++ b/packages/storage/src/sqlite-session-metadata-store.ts
@@ -135,6 +135,8 @@ import {
   type ExternalSessionImportLookupResult,
   type SessionTranscriptMessageLookupRequest,
   type SessionTranscriptPageRequest,
+  type SessionTranscriptRecordScanPage,
+  type SessionTranscriptRecordScanRequest,
   type SessionTranscriptStoragePage,
   type SessionTurnContribution,
   type SessionTurnContributionPage,
@@ -2540,6 +2542,86 @@ export class SqliteSessionMetadataStore {
     });
   }
 
+  async readTranscriptRecords(
+    sessionId: string,
+    request: SessionTranscriptRecordScanRequest,
+  ): Promise<SessionTranscriptRecordScanPage> {
+    this.assertOpen();
+    assertSafeSessionId(sessionId);
+    assertTranscriptRecordScanRequest(request);
+    return this.readTransaction(() => {
+      if (!this.readRecordSync(sessionId)) throw new SessionNotFoundError(sessionId);
+      const highWaterRow = this.db
+        .prepare('SELECT MAX(sequence) AS high_water FROM session_messages WHERE session_id = ?')
+        .get(sessionId) as { high_water?: unknown };
+      const actualHighWater = nullableStoredMessageSequence(highWaterRow.high_water, sessionId);
+      const throughSequence =
+        request.throughSequence === undefined ? actualHighWater : request.throughSequence;
+      if (throughSequence === null) {
+        return { throughSequence: null, records: [], nextPosition: null };
+      }
+      if (actualHighWater === null || throughSequence > actualHighWater) {
+        throw new Error(`Session transcript watermark is ahead of durable storage: ${sessionId}`);
+      }
+      const position = request.position ?? (request.direction === 'older' ? throughSequence : 0);
+      const comparison = request.direction === 'older' ? '<=' : '>=';
+      const order = request.direction === 'older' ? 'DESC' : 'ASC';
+      const rows = this.db
+        .prepare(
+          `
+          SELECT message.sequence,
+            coalesce(payload.record_bytes, length(CAST(message.record_json AS BLOB))) AS stored_bytes
+          FROM session_messages AS message
+          LEFT JOIN session_message_payloads AS payload
+            ON payload.session_id = message.session_id AND payload.sequence = message.sequence
+          WHERE message.session_id = ?
+            AND message.sequence <= ?
+            AND message.sequence ${comparison} ?
+          ORDER BY message.sequence ${order}
+          LIMIT ?
+        `,
+        )
+        .all(sessionId, throughSequence, position, request.maxMessages + 1) as Array<{
+        sequence?: unknown;
+        stored_bytes?: unknown;
+      }>;
+      const selected: number[] = [];
+      let storedBytes = 0;
+      for (const row of rows) {
+        if (selected.length >= request.maxMessages) break;
+        const sequence = requireStoredMessageSequence(row.sequence, sessionId);
+        const bytes = requireTranscriptRecordByteLength(row.stored_bytes, sessionId, sequence);
+        if (selected.length > 0 && storedBytes + bytes > request.maxStoredBytes) break;
+        selected.push(sequence);
+        storedBytes += bytes;
+      }
+      const decoded = new Map<number, StoredMessage>();
+      for (const row of readStoredMessageRows(this.db, sessionId, selected)) {
+        try {
+          decoded.set(row.sequence, decodeStoredMessage(JSON.parse(row.recordJson) as unknown));
+        } catch (error) {
+          throw new StoredSessionMessageIncompatibleError(sessionId, row.sequence, {
+            cause: error,
+          });
+        }
+      }
+      const records = selected.map((sequence) => {
+        const message = decoded.get(sequence);
+        if (!message) throw new StoredSessionMessageIncompatibleError(sessionId, sequence);
+        return { sequence, message };
+      });
+      const last = selected.at(-1);
+      return {
+        throughSequence,
+        records,
+        nextPosition:
+          last !== undefined && rows.length > selected.length
+            ? last + (request.direction === 'older' ? -1 : 1)
+            : null,
+      };
+    });
+  }
+
   async readTranscriptHighWater(sessionId: string): Promise<number | null> {
     this.assertOpen();
     assertSafeSessionId(sessionId);
@@ -7036,3 +7118,36 @@ function assertTranscriptPageRequest(request: SessionTranscriptPageRequest): voi
     throw new Error('Session transcript page message limit must be between 1 and 256');
   }
 }
+
+function assertTranscriptRecordScanRequest(request: SessionTranscriptRecordScanRequest): void {
+  if (request.direction !== 'older' && request.direction !== 'newer') {
+    throw new Error('Invalid Session transcript record direction');
+  }
+  if (
+    request.throughSequence !== undefined &&
+    request.throughSequence !== null &&
+    (!Number.isSafeInteger(request.throughSequence) || request.throughSequence < 0)
+  ) {
+    throw new Error('Invalid Session transcript watermark');
+  }
+  if (
+    request.position !== undefined &&
+    (!Number.isSafeInteger(request.position) || request.position < 0)
+  ) {
+    throw new Error('Invalid Session transcript position');
+  }
+  if (
+    !Number.isSafeInteger(request.maxStoredBytes) ||
+    request.maxStoredBytes < 1 ||
+    request.maxStoredBytes > 16 * 1024 * 1024
+  ) {
+    throw new Error('Invalid Session transcript record byte limit');
+  }
+  if (
+    !Number.isSafeInteger(request.maxMessages) ||
+    request.maxMessages < 1 ||
+    request.maxMessages > 256
+  ) {
+    throw new Error('Invalid Session transcript record count limit');
+  }
+}
diff --git a/packages/ui/src/materialize.ts b/packages/ui/src/materialize.ts
index 916e3db1d..13c15cff9 100644
--- a/packages/ui/src/materialize.ts
+++ b/packages/ui/src/materialize.ts
@@ -17,7 +17,7 @@
  * under the License.
  */
 
-import { deriveTurnRecords } from '@maka/core/session';
+import { deriveTurnRecords, isUserVisibleSessionSystemNote } from '@maka/core/session';
 import {
   isInFlightToolStatus,
   toolResultActivityStatus,
@@ -138,16 +138,6 @@ export interface ToolActivityItem {
   shellRunSource?: "owned" | "unavailable";
 }
 
-// system_note kinds that we surface inline to the user. Everything else
-// (session_resume, connection_locked, mode_change-as-internal-audit, …)
-// stays in the JSONL audit trail but is hidden from the chat surface so
-// the conversation reads like a conversation, not a debug log.
-const VISIBLE_SYSTEM_NOTES = new Set<string>([
-  "context_compacted",
-  "context_compaction_failed_open",
-  "step_limit",
-]);
-
 function systemNoteLabel(kind: string, locale: UiLocale): string {
   const copy = getConversationCopy(locale).messages.systemNotes;
   if (kind === "context_compacted") return copy.contextCompacted;
@@ -189,7 +179,7 @@ export function materializeChat(
       });
     if (
       message.type === "system_note" &&
-      VISIBLE_SYSTEM_NOTES.has(message.kind)
+      isUserVisibleSessionSystemNote(message.kind)
     ) {
       items.push({
         id: message.id,
@@ -774,7 +764,7 @@ export function materializeTurns(
       }
     } else if (
       message.type === "system_note" &&
-      VISIBLE_SYSTEM_NOTES.has(message.kind)
+      isUserVisibleSessionSystemNote(message.kind)
     ) {
       turn.notes.push({
         id: message.id,


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


(maka) 04/04: feat(desktop): approve guest Turn requests (#4198)

Posted by wy...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

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

commit 847e3c909e9d8f7ba4892e12058005554521284a
Author: Wang <m4...@gmail.com>
AuthorDate: Sun Aug 30 08:38:53 2026 +0800

    feat(desktop): approve guest Turn requests (#4198)
    
    Complete the Desktop collaboration loop with Guest Turn requests, durable Owner decisions, and fail-closed read-only behavior when authority is absent.
    
    Generated-by: OpenAI Codex
---
 .../runtime-host-collaboration-ipc-main.test.ts    |  16 +-
 apps/desktop/src/main/runtime-host-client.ts       |  16 +-
 .../main/runtime-host-collaboration-ipc-main.ts    |  61 +++++-
 .../src/main/runtime-host-desktop-candidate.ts     |  14 +-
 apps/desktop/src/preload/bridge-contract.d.ts      |  24 +++
 apps/desktop/src/preload/preload.ts                |  48 ++++-
 apps/desktop/src/renderer/app-shell.tsx            |  38 +++-
 .../renderer/locales/session-collaboration-copy.ts |  47 +++++
 .../src/renderer/session-collaboration-dialog.tsx  | 235 +++++++++++++++++++--
 .../src/renderer/session-turn-request-composer.tsx | 227 ++++++++++++++++++++
 .../src/renderer/styles/settings/runtime-host.css  |  20 ++
 apps/desktop/src/renderer/styles/shell-layout.css  |  48 +++++
 .../renderer/use-session-collaboration-dialog.ts   |   1 +
 docs/astryx-surface-file-inventory.md              |   5 +-
 docs/astryx-surface-file-inventory.paths           |   1 +
 packages/runtime-host/src/protocol/index.ts        |   2 +-
 .../src/protocol/session-collaboration.ts          |  14 +-
 .../runtime-host/src/server/access-authority.ts    |   4 +
 18 files changed, 769 insertions(+), 52 deletions(-)

diff --git a/apps/desktop/src/main/__tests__/runtime-host-collaboration-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-collaboration-ipc-main.test.ts
index 98061fddd..2863fec8e 100644
--- a/apps/desktop/src/main/__tests__/runtime-host-collaboration-ipc-main.test.ts
+++ b/apps/desktop/src/main/__tests__/runtime-host-collaboration-ipc-main.test.ts
@@ -75,12 +75,20 @@ test('requires Owner confirmation before issuing a plaintext collaboration invit
   const prepare = handlers.get('session-collaboration:prepare');
   assert.ok(prepare);
 
-  assert.deepEqual(await prepare({} as Parameters<IpcHandler>[0], 'session-1', false), {
-    kind: 'insecure_confirmation_required',
-  });
+  assert.deepEqual(
+    await prepare({} as Parameters<IpcHandler>[0], 'session-1', 'observe', false),
+    {
+      kind: 'insecure_confirmation_required',
+    },
+  );
   assert.equal(prepareCalls, 0);
 
-  const result = await prepare({} as Parameters<IpcHandler>[0], 'session-1', true);
+  const result = await prepare(
+    {} as Parameters<IpcHandler>[0],
+    'session-1',
+    'observe',
+    true,
+  );
   assert.equal(prepareCalls, 1);
   assert.equal((result as { kind?: unknown }).kind, 'prepared');
   const invitation = (result as {
diff --git a/apps/desktop/src/main/runtime-host-client.ts b/apps/desktop/src/main/runtime-host-client.ts
index 235e4fe2b..e77211678 100644
--- a/apps/desktop/src/main/runtime-host-client.ts
+++ b/apps/desktop/src/main/runtime-host-client.ts
@@ -106,8 +106,10 @@ import {
   type SessionCatalogProjection,
   type SharedSessionCatalogProjection,
   type CollaborationAccessQueryResult,
+  type CollaborationGrantRevokeResult,
   type CollaborationInvitationPrepareResult,
   type CollaborationPrincipalRevokeResult,
+  type CollaborationTurnRequestAcknowledgeResult,
   type CollaborationTurnRequestDecideResult,
   type CollaborationTurnRequestQueryResult,
   type SessionCollaborationGrantKind,
@@ -294,13 +296,19 @@ export class DesktopRuntimeHostClient {
     );
   }
 
+  revokeCollaborationGrant(grantId: string): Promise<CollaborationGrantRevokeResult> {
+    return this.request('collaboration.grant.revoke', { grantId });
+  }
+
   revokeCollaborationPrincipal(
     principalId: string,
   ): Promise<CollaborationPrincipalRevokeResult> {
     return this.request('collaboration.principal.revoke', { principalId });
   }
 
-  createCollaborationTurnRequest(intent: SessionTurnRequestIntent): Promise<SessionTurnAccessRequest> {
+  createCollaborationTurnRequest(
+    intent: SessionTurnRequestIntent,
+  ): Promise<SessionTurnAccessRequest> {
     return this.request('collaboration.turn-request.create', { intent });
   }
 
@@ -308,6 +316,12 @@ export class DesktopRuntimeHostClient {
     return this.request('collaboration.turn-request.query', { sessionId });
   }
 
+  acknowledgeCollaborationTurnRequest(
+    requestId: string,
+  ): Promise<CollaborationTurnRequestAcknowledgeResult> {
+    return this.request('collaboration.turn-request.acknowledge', { requestId });
+  }
+
   decideCollaborationTurnRequest(
     requestId: string,
     decision: 'approve' | 'reject',
diff --git a/apps/desktop/src/main/runtime-host-collaboration-ipc-main.ts b/apps/desktop/src/main/runtime-host-collaboration-ipc-main.ts
index 037749809..622cb75c7 100644
--- a/apps/desktop/src/main/runtime-host-collaboration-ipc-main.ts
+++ b/apps/desktop/src/main/runtime-host-collaboration-ipc-main.ts
@@ -31,7 +31,12 @@ export function registerRuntimeHostCollaborationIpc(
   client: Pick<
     DesktopRuntimeHostClient,
     | 'prepareCollaborationInvitation'
+    | 'acknowledgeCollaborationTurnRequest'
+    | 'createCollaborationTurnRequest'
+    | 'decideCollaborationTurnRequest'
+    | 'queryCollaborationTurnRequests'
     | 'queryCollaborationAccess'
+    | 'revokeCollaborationGrant'
     | 'revokeCollaborationPrincipal'
   >,
   ipcMain: ReconnectableReadIpcMain,
@@ -41,14 +46,21 @@ export function registerRuntimeHostCollaborationIpc(
 ): void {
   ipcMain.handle(
     'session-collaboration:prepare',
-    async (_event, sessionId: unknown, allowInsecure: unknown) => {
+    async (
+      _event,
+      sessionId: unknown,
+      preset: unknown,
+      allowInsecure: unknown,
+    ) => {
       const target = await resolveConnectionTarget();
       if (target.transport.kind === 'plaintext' && allowInsecure !== true) {
         return { kind: 'insecure_confirmation_required' } as const;
       }
       const prepared = await client.prepareCollaborationInvitation(
         requiredId(sessionId, 'Session'),
-        ['session_observation'],
+        requiredPreset(preset) === 'request_turn'
+          ? ['session_observation', 'session_turn_request']
+          : ['session_observation'],
       );
       return {
         kind: 'prepared',
@@ -62,12 +74,43 @@ export function registerRuntimeHostCollaborationIpc(
       };
     },
   );
+  ipcMain.handle(
+    'session-collaboration:turn-request:create',
+    (_event, intent: unknown) =>
+      client.createCollaborationTurnRequest(intent as Parameters<
+        DesktopRuntimeHostClient['createCollaborationTurnRequest']
+      >[0]),
+  );
+  handleReconnectableRead(
+    ipcMain,
+    'session-collaboration:turn-request:query',
+    (_event, sessionId: unknown) =>
+      client.queryCollaborationTurnRequests(requiredId(sessionId, 'Session')),
+  );
+  ipcMain.handle(
+    'session-collaboration:turn-request:acknowledge',
+    (_event, requestId: unknown) =>
+      client.acknowledgeCollaborationTurnRequest(requiredId(requestId, 'Turn request')),
+  );
+  ipcMain.handle(
+    'session-collaboration:turn-request:decide',
+    (_event, requestId: unknown, decision: unknown) =>
+      client.decideCollaborationTurnRequest(
+        requiredId(requestId, 'Turn request'),
+        requiredDecision(decision),
+      ),
+  );
   handleReconnectableRead(
     ipcMain,
     'session-collaboration:getAccess',
     (_event, sessionId: unknown) =>
       client.queryCollaborationAccess(requiredId(sessionId, 'Session')),
   );
+  ipcMain.handle(
+    'session-collaboration:revokeGrant',
+    (_event, grantId: unknown) =>
+      client.revokeCollaborationGrant(requiredId(grantId, 'Grant')),
+  );
   ipcMain.handle(
     'session-collaboration:revokePrincipal',
     (_event, principalId: unknown) =>
@@ -86,3 +129,17 @@ function requiredId(value: unknown, label: string): string {
   }
   return value;
 }
+
+function requiredPreset(value: unknown): 'observe' | 'request_turn' {
+  if (value !== 'observe' && value !== 'request_turn') {
+    throw new Error('Invalid collaboration invitation preset');
+  }
+  return value;
+}
+
+function requiredDecision(value: unknown): 'approve' | 'reject' {
+  if (value !== 'approve' && value !== 'reject') {
+    throw new Error('Invalid collaboration Turn request decision');
+  }
+  return value;
+}
diff --git a/apps/desktop/src/main/runtime-host-desktop-candidate.ts b/apps/desktop/src/main/runtime-host-desktop-candidate.ts
index 51ff440b9..53faa70a3 100644
--- a/apps/desktop/src/main/runtime-host-desktop-candidate.ts
+++ b/apps/desktop/src/main/runtime-host-desktop-candidate.ts
@@ -811,14 +811,14 @@ export async function createDesktopRuntimeHostCandidate(
         ipc,
       );
     }
+    registerRuntimeHostCollaborationIpc(client, ipc, async () => {
+      if (collaborationConnectionTarget) return collaborationConnectionTarget;
+      if (target.kind === 'local' && deps.resolveLocalCollaborationConnectionTarget) {
+        return deps.resolveLocalCollaborationConnectionTarget();
+      }
+      throw new Error('This Runtime Host does not have a shareable connection target');
+    });
     if (target.access === 'owner') {
-      registerRuntimeHostCollaborationIpc(client, ipc, async () => {
-        if (collaborationConnectionTarget) return collaborationConnectionTarget;
-        if (target.kind === 'local' && deps.resolveLocalCollaborationConnectionTarget) {
-          return deps.resolveLocalCollaborationConnectionTarget();
-        }
-        throw new Error('This Runtime Host does not have a shareable connection target');
-      });
       registerRuntimeHostWorkHubIpc(client, ipc, {
         resolveCreateProject: () => deps.resolveSessionCreateProject({}, target),
         emitSessionsChanged,
diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts
index 917aec24b..df55b7d7a 100644
--- a/apps/desktop/src/preload/bridge-contract.d.ts
+++ b/apps/desktop/src/preload/bridge-contract.d.ts
@@ -111,8 +111,13 @@ import type {
 } from '@maka/runtime-host/protocol';
 import type {
   CollaborationAccessQueryResult,
+  CollaborationGrantRevokeResult,
   CollaborationInvitationPrepareResult,
   CollaborationPrincipalRevokeResult,
+  CollaborationTurnRequestAcknowledgeResult,
+  CollaborationTurnRequestDecideResult,
+  CollaborationTurnRequestQueryResult,
+  SessionTurnAccessRequest,
 } from '@maka/runtime-host/protocol';
 import type { AgentGraphEpochDirectory } from '@maka/runtime-host/client';
 import type {
@@ -676,9 +681,14 @@ export interface MakaBridge {
   sessionCollaboration: {
     prepareInvitation(
       sessionId: string,
+      preset: 'observe' | 'request_turn',
       allowInsecure?: boolean,
     ): Promise<DesktopSessionCollaborationPrepareResult>;
     getAccess(sessionId: string): Promise<CollaborationAccessQueryResult>;
+    revokeGrant(
+      sessionId: string,
+      grantId: string,
+    ): Promise<CollaborationGrantRevokeResult>;
     revokePrincipal(
       sessionId: string,
       principalId: string,
@@ -687,6 +697,20 @@ export interface MakaBridge {
       readonly code: string;
       readonly allowInsecure?: boolean;
     }): Promise<DesktopSessionCollaborationImportResult>;
+    requestTurn(
+      sessionId: string,
+      input: { readonly turnId: string; readonly text: string },
+    ): Promise<SessionTurnAccessRequest>;
+    getTurnRequests(sessionId: string): Promise<CollaborationTurnRequestQueryResult>;
+    acknowledgeTurnRequest(
+      sessionId: string,
+      requestId: string,
+    ): Promise<CollaborationTurnRequestAcknowledgeResult>;
+    decideTurnRequest(
+      sessionId: string,
+      requestId: string,
+      decision: 'approve' | 'reject',
+    ): Promise<CollaborationTurnRequestDecideResult>;
   };
 
   runtimeHost: {
diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts
index 158a8e100..c3f702587 100644
--- a/apps/desktop/src/preload/preload.ts
+++ b/apps/desktop/src/preload/preload.ts
@@ -1180,12 +1180,13 @@ const browserSelection = createBrowserSelectionCoordinator(runtimeHostSessionRef
 const makaBridge = {
   runtimeHost,
   sessionCollaboration: {
-    async prepareInvitation(sessionId, allowInsecure = false) {
+    async prepareInvitation(sessionId, preset, allowInsecure = false) {
       const session = await runtimeHostSessionRef(sessionId);
       return ipcRenderer.invoke(
         'session-collaboration:prepare',
         session.scope,
         session.sessionId,
+        preset,
         allowInsecure,
       );
     },
@@ -1205,9 +1206,54 @@ const makaBridge = {
         principalId,
       );
     },
+    async revokeGrant(sessionId, grantId) {
+      const session = await runtimeHostSessionRef(sessionId);
+      return ipcRenderer.invoke(
+        'session-collaboration:revokeGrant',
+        session.scope,
+        grantId,
+      );
+    },
     importInvitation({ code, allowInsecure = false }) {
       return ipcRenderer.invoke('session-collaboration:import', code, allowInsecure);
     },
+    async requestTurn(sessionId, input) {
+      const session = await runtimeHostSessionRef(sessionId);
+      return ipcRenderer.invoke(
+        'session-collaboration:turn-request:create',
+        session.scope,
+        {
+          sessionId: session.sessionId,
+          turnId: input.turnId,
+          content: { text: input.text },
+        },
+      );
+    },
+    async getTurnRequests(sessionId) {
+      const session = await runtimeHostSessionRef(sessionId);
+      return ipcRenderer.invoke(
+        'session-collaboration:turn-request:query',
+        session.scope,
+        session.sessionId,
+      );
+    },
+    async acknowledgeTurnRequest(sessionId, requestId) {
+      const session = await runtimeHostSessionRef(sessionId);
+      return ipcRenderer.invoke(
+        'session-collaboration:turn-request:acknowledge',
+        session.scope,
+        requestId,
+      );
+    },
+    async decideTurnRequest(sessionId, requestId, decision) {
+      const session = await runtimeHostSessionRef(sessionId);
+      return ipcRenderer.invoke(
+        'session-collaboration:turn-request:decide',
+        session.scope,
+        requestId,
+        decision,
+      );
+    },
   },
   runtimeHostProfiles: {
     getSnapshot() {
diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx
index edb6e038f..f6bded74b 100644
--- a/apps/desktop/src/renderer/app-shell.tsx
+++ b/apps/desktop/src/renderer/app-shell.tsx
@@ -108,6 +108,7 @@ import {
 } from './features/task-entry';
 import { useNewTaskChoice } from './use-new-task-choice';
 import { SessionCollaborationDialog } from './session-collaboration-dialog';
+import { SessionTurnRequestComposer } from './session-turn-request-composer.js';
 import { getSessionCollaborationCopy } from './locales/session-collaboration-copy';
 import { useSessionCollaborationDialog } from './use-session-collaboration-dialog';
 import { NEW_TASK_PENDING_KEY } from './pending-items';
@@ -835,6 +836,13 @@ function AppShellContent({
   const activeMessageQueue = activeId ? messageQueueBySession[activeId] : undefined;
   const activeMessageSubmitting = transientMessages.length > 0;
   const activeDesktopSession = activeSession;
+  function openSessionSharing(session: DesktopSessionSummary): void {
+    sharedSessionDialog.open({
+      sessionId: session.id,
+      sessionName: session.name,
+      requiresRemoteAccess: session.profileKind === 'local',
+    });
+  }
   // The shell's reading of the active live turn: streaming/settled flags, the
   // in-flight tool signal, and the #646 turn-wait cues, all derived from the
   // semantic snapshot rather than the projection (#1985).
@@ -2779,13 +2787,16 @@ function AppShellContent({
                 key={activeSessionForView.id}
                 sessionName={activeSessionForView.name}
                 readOnly={sharedSessionActive}
-                action={sharedSessionActive ? undefined : {
-                  label: getSessionCollaborationCopy(uiLocale).shareAction,
-                  onClick: () => sharedSessionDialog.open({
-                    sessionId: activeSessionForView.id,
-                    sessionName: activeSessionForView.name,
-                  }),
-                }}
+                action={
+                  sharedSessionActive ||
+                  !activeDesktopSession ||
+                  activeDesktopSession.profileKind === 'environment'
+                    ? undefined
+                    : {
+                        label: getSessionCollaborationCopy(uiLocale).shareAction,
+                        onClick: () => void openSessionSharing(activeDesktopSession),
+                      }
+                }
                 onRenameSession={(name) => {
                   void sessionNavigationCommandsRef.current?.renameSession(activeSessionForView.id, name);
                 }}
@@ -2918,10 +2929,8 @@ function AppShellContent({
                         onClick={openWorkHub}
                       />
                     ) : null}
-                    {sharedSessionActive ? (
-                      <div className="sessionCollaborationReadOnly">
-                        {getSessionCollaborationCopy(uiLocale).observeHelp}
-                      </div>
+                    {sharedSessionActive && activeId ? (
+                      <SessionTurnRequestComposer key={activeId} sessionId={activeId} />
                     ) : (
                     <ChatComposerRegion
                   workspacePicker={workspacePicker}
@@ -3252,6 +3261,13 @@ function AppShellContent({
           mode="share"
           sessionId={sharedSessionDialog.target.sessionId}
           sessionName={sharedSessionDialog.target.sessionName}
+          requiresRemoteAccess={sharedSessionDialog.target.requiresRemoteAccess}
+          onEnableRemoteAccess={() => {
+            const copy = getSessionCollaborationCopy(uiLocale);
+            sharedSessionDialog.close();
+            toastApi.info(copy.enableRemoteAccessTitle, copy.enableRemoteAccessBody);
+            openSettingsSection('projects');
+          }}
           onClose={sharedSessionDialog.close}
         />
       ) : null}
diff --git a/apps/desktop/src/renderer/locales/session-collaboration-copy.ts b/apps/desktop/src/renderer/locales/session-collaboration-copy.ts
index 0a7779bb2..9e9a71b85 100644
--- a/apps/desktop/src/renderer/locales/session-collaboration-copy.ts
+++ b/apps/desktop/src/renderer/locales/session-collaboration-copy.ts
@@ -23,11 +23,17 @@ const ZH = {
     shareAction: '分享此任务',
     shareTitle: '分享任务',
     shareDescription: '创建一次性邀请,让另一台 Maka 访问这个任务。',
+    enableRemoteAccessTitle: '先开启本机远程访问',
+    enableRemoteAccessBody: '已为你打开 Runtime Host 设置;开启远程访问后即可分享此任务。',
     disclosureTitle: '分享前请确认',
     disclosureBody:
       '对方将看到这个任务已有和之后产生的全部可见内容,包括其中已经出现的文件路径、凭据或其他秘密。撤销访问只能阻止后续读取,无法收回对方已经复制的内容。',
+    accessLabel: '访问权限',
     observe: '只读',
     observeHelp: '查看完整历史和实时更新',
+    requestTurn: '可请求新轮次',
+    revokeTurnRequests: '撤销轮次请求权限',
+    requestTurnHelp: '查看完整内容,并提交需要你逐次批准的新轮次请求',
     createInvitation: '创建邀请',
     invitationCode: '一次性邀请码',
     invitationHelp: '邀请码包含连接地址和访客凭据,不包含所有者凭据。',
@@ -35,6 +41,8 @@ const ZH = {
     copied: '邀请码已复制',
     close: '完成',
     activeAccess: '当前访问',
+    accessUnavailable: '共享管理暂时不可用;此窗口会自动重试。',
+    guest: '访客',
     noAccess: '尚未分享给任何人',
     pending: '待领取',
     active: '已连接',
@@ -51,6 +59,21 @@ const ZH = {
     shareInsecure: '接受风险并创建',
     joinInsecure: '接受风险并加入',
     sharedBadge: '共享',
+    turnRequests: '轮次请求',
+    noTurnRequests: '暂无轮次请求',
+    approve: '批准',
+    reject: '拒绝',
+    turnRequestPlaceholder: '描述你希望发起的新轮次',
+    submitTurnRequest: '请求新轮次',
+    turnRequestSent: '请求已提交,等待所有者批准',
+    turnRequestReconciling: '正在确认请求是否已被 Host 接收…',
+    turnRequestPending: '等待批准',
+    turnRequestApproved: '已批准',
+    turnRequestRejected: '已拒绝',
+    turnRequestStarted: '已开始',
+    turnRequestBlocked: '未能开始',
+    turnRequestFailed: '准入失败',
+    dismissTurnRequest: '关闭',
 };
 
 type SessionCollaborationCopy = {
@@ -61,11 +84,18 @@ const EN = {
     shareAction: 'Share this task',
     shareTitle: 'Share task',
     shareDescription: 'Create a one-time invitation for another Maka installation.',
+    enableRemoteAccessTitle: 'Enable remote access first',
+    enableRemoteAccessBody:
+      'Runtime Host settings are open. Enable remote access there, then share this task.',
     disclosureTitle: 'Confirm what will be shared',
     disclosureBody:
       'The Guest can read all existing and future visible content in this task, including paths, credentials, or other secrets already present. Revoking access stops future reads but cannot retract copied content.',
+    accessLabel: 'Access',
     observe: 'Read only',
     observeHelp: 'Read the full history and live updates',
+    requestTurn: 'Can request Turns',
+    revokeTurnRequests: 'Revoke Turn requests',
+    requestTurnHelp: 'Read the task and propose new Turns for your individual approval',
     createInvitation: 'Create invitation',
     invitationCode: 'One-time invitation code',
     invitationHelp: 'The code contains the connection address and Guest credential, never the Owner credential.',
@@ -73,6 +103,8 @@ const EN = {
     copied: 'Invitation copied',
     close: 'Done',
     activeAccess: 'Current access',
+    accessUnavailable: 'Sharing controls are temporarily unavailable. This window will retry.',
+    guest: 'Guest',
     noAccess: 'No one has access yet',
     pending: 'Pending',
     active: 'Connected',
@@ -89,6 +121,21 @@ const EN = {
     shareInsecure: 'Accept risk and create',
     joinInsecure: 'Accept risk and join',
     sharedBadge: 'Shared',
+    turnRequests: 'Turn requests',
+    noTurnRequests: 'No Turn requests yet',
+    approve: 'Approve',
+    reject: 'Reject',
+    turnRequestPlaceholder: 'Describe the new Turn you want to start',
+    submitTurnRequest: 'Request new Turn',
+    turnRequestSent: 'Request sent for Owner approval',
+    turnRequestReconciling: 'Checking whether the Host received this request…',
+    turnRequestPending: 'Awaiting approval',
+    turnRequestApproved: 'Approved',
+    turnRequestRejected: 'Rejected',
+    turnRequestStarted: 'Started',
+    turnRequestBlocked: 'Could not start',
+    turnRequestFailed: 'Admission failed',
+    dismissTurnRequest: 'Dismiss',
 } satisfies SessionCollaborationCopy;
 
 const COPY = { zh: ZH, en: EN } satisfies UiCatalog<SessionCollaborationCopy>;
diff --git a/apps/desktop/src/renderer/session-collaboration-dialog.tsx b/apps/desktop/src/renderer/session-collaboration-dialog.tsx
index f077e944b..eb7e2b06f 100644
--- a/apps/desktop/src/renderer/session-collaboration-dialog.tsx
+++ b/apps/desktop/src/renderer/session-collaboration-dialog.tsx
@@ -17,9 +17,10 @@
  * under the License.
  */
 
-import { useEffect, useState } from 'react';
+import { useEffect, useMemo, useState } from 'react';
 import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog';
 import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout';
+import { SegmentedControl, SegmentedControlItem } from '@astryxdesign/core';
 import {
   Button,
   FormLayout,
@@ -31,14 +32,19 @@ import {
 import type {
   CollaborationAccessQueryResult,
   CollaborationInvitationPrepareResult,
+  SessionCollaborationGrant,
+  SessionTurnAccessRequest,
 } from '@maka/runtime-host/protocol';
 import { getSessionCollaborationCopy } from './locales/session-collaboration-copy.js';
+import { turnRequestStateLabel } from './session-turn-request-composer.js';
 
 type Props =
   | {
       readonly mode: 'share';
       readonly sessionId: string;
       readonly sessionName: string;
+      readonly requiresRemoteAccess: boolean;
+      readonly onEnableRemoteAccess: () => void;
       readonly onClose: () => void;
     }
   | {
@@ -47,6 +53,12 @@ type Props =
       readonly onClose: () => void;
     };
 
+type CollaborationAuthorityState =
+  | 'loading'
+  | 'available'
+  | 'remote_access_off'
+  | 'unavailable';
+
 export function SessionCollaborationDialog(props: Props) {
   return props.mode === 'share'
     ? <ShareSessionDialog {...props} />
@@ -56,37 +68,95 @@ export function SessionCollaborationDialog(props: Props) {
 function ShareSessionDialog(props: Extract<Props, { readonly mode: 'share' }>) {
   const copy = getSessionCollaborationCopy(useUiLocale());
   const toast = useToast();
+  const [preset, setPreset] = useState<'observe' | 'request_turn'>('observe');
   const [access, setAccess] = useState<CollaborationAccessQueryResult>();
   const [invitation, setInvitation] = useState<CollaborationInvitationPrepareResult>();
+  const [turnRequests, setTurnRequests] = useState<readonly SessionTurnAccessRequest[]>();
+  const [authorityState, setAuthorityState] = useState<CollaborationAuthorityState>('loading');
   const [working, setWorking] = useState(false);
 
-  async function refresh(): Promise<void> {
-    const nextAccess = await window.maka.sessionCollaboration.getAccess(props.sessionId);
-    setAccess(nextAccess);
+  async function readProjection() {
+    if (props.requiresRemoteAccess) {
+      const remoteAccess = await window.maka.localRuntimeHostRemoteAccess.getSnapshot();
+      if (remoteAccess.state !== 'on') return { kind: 'remote_access_off' } as const;
+    }
+    const [nextAccess, nextRequests] = await Promise.all([
+      window.maka.sessionCollaboration.getAccess(props.sessionId),
+      window.maka.sessionCollaboration.getTurnRequests(props.sessionId),
+    ]);
+    return {
+      kind: 'available',
+      access: nextAccess,
+      turnRequests: nextRequests.requests,
+    } as const;
+  }
+
+  function applyProjection(projection: Awaited<ReturnType<typeof readProjection>>): void {
+    if (projection.kind === 'remote_access_off') {
+      setAuthorityState('remote_access_off');
+      return;
+    }
+    setAccess(projection.access);
+    setTurnRequests(projection.turnRequests);
+    setAuthorityState('available');
     setInvitation((current) => {
       if (!current || Date.parse(current.expiresAt) <= Date.now()) return undefined;
-      const principal = nextAccess.principals.find(
+      const principal = projection.access.principals.find(
         (candidate) => candidate.principalId === current.principalId,
       );
       return principal?.status === 'pending' ? current : undefined;
     });
   }
 
+  async function refresh(): Promise<void> {
+    try {
+      applyProjection(await readProjection());
+    } catch {
+      setAuthorityState('unavailable');
+    }
+  }
+
   useEffect(() => {
-    const poll = () => void refresh().catch(() => undefined);
-    void refresh().catch((error) => toast.error(copy.shareTitle, errorMessage(error)));
-    const timer = window.setInterval(poll, 2_000);
-    return () => window.clearInterval(timer);
-  }, [props.sessionId]);
+    let disposed = false;
+    let timer: number | undefined;
+    const poll = async () => {
+      try {
+        const projection = await readProjection();
+        if (!disposed) applyProjection(projection);
+      } catch {
+        if (!disposed) setAuthorityState('unavailable');
+      } finally {
+        if (!disposed) timer = window.setTimeout(() => void poll(), 2_000);
+      }
+    };
+    void poll();
+    return () => {
+      disposed = true;
+      if (timer !== undefined) window.clearTimeout(timer);
+    };
+  }, [props.sessionId, props.requiresRemoteAccess]);
 
   async function createInvitation(allowInsecure = false): Promise<void> {
     setWorking(true);
     try {
-      const result = await window.maka.sessionCollaboration.prepareInvitation(
+      if (authorityState === 'remote_access_off') {
+        props.onEnableRemoteAccess();
+        return;
+      }
+      if (authorityState !== 'available') return;
+      if (props.requiresRemoteAccess) {
+        const access = await window.maka.localRuntimeHostRemoteAccess.getSnapshot();
+        if (access.state !== 'on') {
+          props.onEnableRemoteAccess();
+          return;
+        }
+      }
+      const created = await window.maka.sessionCollaboration.prepareInvitation(
         props.sessionId,
+        preset,
         allowInsecure,
       );
-      if (result.kind === 'insecure_confirmation_required') {
+      if (created.kind === 'insecure_confirmation_required') {
         const confirmed = await toast.confirm({
           title: copy.insecureTitle,
           description: copy.insecureBody,
@@ -96,7 +166,7 @@ function ShareSessionDialog(props: Extract<Props, { readonly mode: 'share' }>) {
         if (confirmed) await createInvitation(true);
         return;
       }
-      setInvitation(result.invitation);
+      setInvitation(created.invitation);
       await refresh();
     } catch (error) {
       toast.error(copy.shareTitle, errorMessage(error));
@@ -127,6 +197,49 @@ function ShareSessionDialog(props: Extract<Props, { readonly mode: 'share' }>) {
     }
   }
 
+  async function revokeGrant(grant: SessionCollaborationGrant): Promise<void> {
+    setWorking(true);
+    try {
+      await window.maka.sessionCollaboration.revokeGrant(
+        props.sessionId,
+        grant.grantId,
+      );
+      await refresh();
+    } catch (error) {
+      toast.error(copy.shareTitle, errorMessage(error));
+    } finally {
+      setWorking(false);
+    }
+  }
+
+  async function decideTurnRequest(
+    request: SessionTurnAccessRequest,
+    decision: 'approve' | 'reject',
+  ): Promise<void> {
+    setWorking(true);
+    try {
+      await window.maka.sessionCollaboration.decideTurnRequest(
+        props.sessionId,
+        request.requestId,
+        decision,
+      );
+      await refresh();
+    } catch (error) {
+      toast.error(copy.turnRequests, errorMessage(error));
+    } finally {
+      setWorking(false);
+    }
+  }
+
+  const grantsByPrincipal = useMemo(() => {
+    const groups = new Map<string, SessionCollaborationGrant[]>();
+    for (const grant of access?.grants ?? []) {
+      const grants = groups.get(grant.principalId) ?? [];
+      grants.push(grant);
+      groups.set(grant.principalId, grants);
+    }
+    return groups;
+  }, [access]);
   return (
     <Dialog isOpen onOpenChange={(open) => !open && !working && props.onClose()} purpose="form" width={620}>
       <Layout
@@ -138,7 +251,20 @@ function ShareSessionDialog(props: Extract<Props, { readonly mode: 'share' }>) {
                 <Text type="body" weight="semibold">{copy.disclosureTitle}</Text>
                 <Text type="supporting" color="secondary">{copy.disclosureBody}</Text>
               </section>
-              <Text type="supporting" color="secondary">{copy.observeHelp}</Text>
+              <SegmentedControl
+                label={copy.accessLabel}
+                value={preset}
+                layout="fill"
+                size="sm"
+                isDisabled={working || invitation !== undefined}
+                onChange={(value) => setPreset(value as typeof preset)}
+              >
+                <SegmentedControlItem value="observe" label={copy.observe} />
+                <SegmentedControlItem value="request_turn" label={copy.requestTurn} />
+              </SegmentedControl>
+              <Text type="supporting" color="secondary">
+                {preset === 'observe' ? copy.observeHelp : copy.requestTurnHelp}
+              </Text>
               {invitation ? (
                 <FormLayout>
                   <TextArea
@@ -156,31 +282,93 @@ function ShareSessionDialog(props: Extract<Props, { readonly mode: 'share' }>) {
                 <Button
                   variant="primary"
                   label={copy.createInvitation}
-                  isDisabled={working}
+                  isDisabled={
+                    working ||
+                    (authorityState !== 'available' && authorityState !== 'remote_access_off')
+                  }
                   onClick={() => void createInvitation()}
                 />
               )}
+              {authorityState === 'remote_access_off' ? (
+                <Text type="supporting" color="secondary">{copy.enableRemoteAccessBody}</Text>
+              ) : authorityState === 'unavailable' ? (
+                <Text type="supporting" color="secondary">{copy.accessUnavailable}</Text>
+              ) : null}
               <section className="sessionCollaborationAccess">
                 <Text type="body" weight="semibold">{copy.activeAccess}</Text>
-                {(access?.principals.length ?? 0) === 0 ? (
+                {access?.principals.length === 0 ? (
                   <Text type="supporting" color="secondary">{copy.noAccess}</Text>
-                ) : access?.principals.map((principal) => (
+                ) : access?.principals.map((principal) => {
+                  const grants = grantsByPrincipal.get(principal.principalId) ?? [];
+                  const requestGrant = grants.find((grant) => grant.kind === 'session_turn_request');
+                  return (
                     <div className="sessionCollaborationAccessRow" key={principal.principalId}>
                       <div>
-                        <Text type="body">{principal.status === 'pending' ? copy.pending : copy.active}</Text>
-                        <Text type="supporting" color="secondary">{copy.observe}</Text>
+                        <Text type="body">{guestIdentityLabel(principal.principalId, copy.guest)}</Text>
+                        <Text type="supporting" color="secondary">
+                          {principal.status === 'pending' ? copy.pending : copy.active}
+                          {' · '}
+                          {requestGrant ? `${copy.observe} · ${copy.requestTurn}` : copy.observe}
+                        </Text>
                       </div>
                       <div className="sessionCollaborationAccessActions">
+                        {requestGrant ? (
+                          <Button
+                            variant="ghost"
+                            size="sm"
+                            label={copy.revokeTurnRequests}
+                            isDisabled={working || authorityState !== 'available'}
+                            onClick={() => void revokeGrant(requestGrant)}
+                          />
+                        ) : null}
                         <Button
                           variant="secondary"
                           size="sm"
                           label={copy.revoke}
-                          isDisabled={working}
+                          isDisabled={working || authorityState !== 'available'}
                           onClick={() => void revokePrincipal(principal.principalId)}
                         />
                       </div>
                     </div>
-                  ))}
+                  );
+                })}
+              </section>
+              <section className="sessionCollaborationAccess">
+                <Text type="body" weight="semibold">{copy.turnRequests}</Text>
+                {turnRequests?.length === 0 ? (
+                  <Text type="supporting" color="secondary">{copy.noTurnRequests}</Text>
+                ) : turnRequests?.map((request) => (
+                  <div className="sessionCollaborationTurnRequest" key={request.requestId}>
+                    <div>
+                      <Text type="body" className="sessionCollaborationTurnRequestText">
+                        {request.intent.content.text}
+                      </Text>
+                      <Text type="supporting" color="secondary">
+                        {guestIdentityLabel(request.principalId, copy.guest)}
+                        {' · '}
+                        {turnRequestStateLabel(request, copy)}
+                      </Text>
+                    </div>
+                    {request.state.kind === 'pending' ? (
+                      <div className="sessionCollaborationAccessActions">
+                        <Button
+                          variant="secondary"
+                          size="sm"
+                          label={copy.reject}
+                          isDisabled={working || authorityState !== 'available'}
+                          onClick={() => void decideTurnRequest(request, 'reject')}
+                        />
+                        <Button
+                          variant="primary"
+                          size="sm"
+                          label={copy.approve}
+                          isDisabled={working || authorityState !== 'available'}
+                          onClick={() => void decideTurnRequest(request, 'approve')}
+                        />
+                      </div>
+                    ) : null}
+                  </div>
+                ))}
               </section>
             </div>
           </LayoutContent>
@@ -191,6 +379,11 @@ function ShareSessionDialog(props: Extract<Props, { readonly mode: 'share' }>) {
   );
 }
 
+function guestIdentityLabel(principalId: string, label: string): string {
+  const identity = principalId.includes(':') ? principalId.slice(principalId.lastIndexOf(':') + 1) : principalId;
+  return `${label} ${identity.slice(0, 8)}`;
+}
+
 function JoinSharedSessionDialog(props: Extract<Props, { readonly mode: 'join' }>) {
   const copy = getSessionCollaborationCopy(useUiLocale());
   const toast = useToast();
diff --git a/apps/desktop/src/renderer/session-turn-request-composer.tsx b/apps/desktop/src/renderer/session-turn-request-composer.tsx
new file mode 100644
index 000000000..f6a7789ff
--- /dev/null
+++ b/apps/desktop/src/renderer/session-turn-request-composer.tsx
@@ -0,0 +1,227 @@
+/*
+ * 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 { useEffect, useRef, useState } from 'react';
+import { Button, Text, TextArea, useToast, useUiLocale } from '@maka/ui';
+import type { SessionTurnAccessRequest } from '@maka/runtime-host/protocol';
+import { getSessionCollaborationCopy } from './locales/session-collaboration-copy.js';
+
+export function SessionTurnRequestComposer(props: {
+  readonly sessionId: string;
+}) {
+  const copy = getSessionCollaborationCopy(useUiLocale());
+  const toast = useToast();
+  const [text, setText] = useState('');
+  const [requests, setRequests] = useState<readonly SessionTurnAccessRequest[]>([]);
+  const [canRequestTurns, setCanRequestTurns] = useState(false);
+  const [authorityAvailable, setAuthorityAvailable] = useState<boolean>();
+  const [reconciling, setReconciling] = useState(false);
+  const [working, setWorking] = useState(false);
+  const attemptRef = useRef<{ readonly turnId: string; readonly text: string } | undefined>(
+    undefined,
+  );
+  const reconcilingTurnIdRef = useRef<string | undefined>(undefined);
+
+  function acceptRequest(request: SessionTurnAccessRequest): void {
+    setRequests((current) =>
+      current.some((candidate) => candidate.requestId === request.requestId)
+        ? current
+        : [...current, request],
+    );
+    attemptRef.current = undefined;
+    reconcilingTurnIdRef.current = undefined;
+    setReconciling(false);
+    setText('');
+    toast.success(copy.turnRequestSent);
+  }
+
+  function applyProjection(result: {
+    readonly canRequestTurns: boolean;
+    readonly requests: readonly SessionTurnAccessRequest[];
+  }): SessionTurnAccessRequest | undefined {
+    setAuthorityAvailable(true);
+    setCanRequestTurns(result.canRequestTurns);
+    setRequests(result.requests);
+    const turnId = reconcilingTurnIdRef.current;
+    if (!turnId) return;
+    const request = result.requests.find((candidate) => candidate.intent.turnId === turnId);
+    if (request) acceptRequest(request);
+    else {
+      reconcilingTurnIdRef.current = undefined;
+      setReconciling(false);
+    }
+    return request;
+  }
+
+  useEffect(() => {
+    let disposed = false;
+    let timer: number | undefined;
+    const refresh = async () => {
+      try {
+        const result = await window.maka.sessionCollaboration.getTurnRequests(props.sessionId);
+        if (!disposed) {
+          applyProjection(result);
+        }
+      } catch {
+        if (!disposed) setAuthorityAvailable(false);
+        // The Host remains authoritative; a later refresh or submit retries the projection.
+      } finally {
+        if (!disposed) timer = window.setTimeout(() => void refresh(), 2_000);
+      }
+    };
+    void refresh();
+    return () => {
+      disposed = true;
+      if (timer !== undefined) window.clearTimeout(timer);
+    };
+  }, [props.sessionId]);
+
+  async function submit(): Promise<void> {
+    const content = text.trim();
+    if (!content) return;
+    const attempt = attemptRef.current?.text === content
+      ? attemptRef.current
+      : { turnId: crypto.randomUUID(), text: content };
+    attemptRef.current = attempt;
+    setWorking(true);
+    try {
+      const request = await window.maka.sessionCollaboration.requestTurn(
+        props.sessionId,
+        attempt,
+      );
+      acceptRequest(request);
+    } catch (error) {
+      try {
+        const current = await window.maka.sessionCollaboration.getTurnRequests(props.sessionId);
+        reconcilingTurnIdRef.current = attempt.turnId;
+        const request = applyProjection(current);
+        if (request) {
+          return;
+        }
+      } catch {
+        reconcilingTurnIdRef.current = attempt.turnId;
+        setReconciling(true);
+        setAuthorityAvailable(false);
+        return;
+      }
+      toast.error(copy.submitTurnRequest, errorMessage(error));
+    } finally {
+      setWorking(false);
+    }
+  }
+
+  async function dismiss(requestId: string): Promise<void> {
+    try {
+      await window.maka.sessionCollaboration.acknowledgeTurnRequest(
+        props.sessionId,
+        requestId,
+      );
+      setRequests((current) =>
+        current.filter((request) => request.requestId !== requestId),
+      );
+    } catch (error) {
+      toast.error(copy.turnRequests, errorMessage(error));
+    }
+  }
+
+  return (
+    <div className="sessionTurnRequestSurface">
+      {requests.length > 0 ? (
+        <div className="sessionTurnRequestHistory" aria-label={copy.turnRequests}>
+          {requests.slice().reverse().map((request) => (
+            <div className="sessionTurnRequestHistoryRow" key={request.requestId}>
+              <Text type="body" className="sessionTurnRequestHistoryText">
+                {request.intent.content.text}
+              </Text>
+              <Text type="supporting" color="secondary">
+                {turnRequestStateLabel(request, copy)}
+              </Text>
+              {isTurnRequestTerminal(request) ? (
+                <Button
+                  variant="ghost"
+                  size="sm"
+                  label={copy.dismissTurnRequest}
+                  isDisabled={authorityAvailable !== true}
+                  onClick={() => void dismiss(request.requestId)}
+                />
+              ) : null}
+            </div>
+          ))}
+        </div>
+      ) : null}
+      {authorityAvailable === false ? (
+        <div className="sessionCollaborationReadOnly">
+          {reconciling ? copy.turnRequestReconciling : copy.accessUnavailable}
+        </div>
+      ) : authorityAvailable === true && canRequestTurns ? (
+        <div className="sessionTurnRequestComposer">
+          <TextArea
+            label={copy.turnRequestPlaceholder}
+            isLabelHidden
+            value={text}
+            rows={2}
+            placeholder={copy.turnRequestPlaceholder}
+            isDisabled={working || reconciling}
+            onChange={(value) => {
+              if (value.trim() !== attemptRef.current?.text) attemptRef.current = undefined;
+              setText(value);
+            }}
+          />
+          <div className="sessionTurnRequestComposerFooter">
+            <Text type="supporting" color="secondary">
+              {copy.requestTurnHelp}
+            </Text>
+            <Button
+              variant="primary"
+              size="sm"
+              label={copy.submitTurnRequest}
+              isDisabled={working || reconciling || text.trim().length === 0}
+              onClick={() => void submit()}
+            />
+          </div>
+        </div>
+      ) : authorityAvailable === true ? (
+        <div className="sessionCollaborationReadOnly">{copy.observeHelp}</div>
+      ) : null}
+    </div>
+  );
+}
+
+function isTurnRequestTerminal(request: SessionTurnAccessRequest): boolean {
+  return (
+    request.state.kind === 'rejected' ||
+    (request.state.kind === 'approved' && request.state.admission !== 'pending')
+  );
+}
+
+export function turnRequestStateLabel(
+  request: SessionTurnAccessRequest,
+  copy: ReturnType<typeof getSessionCollaborationCopy>,
+): string {
+  if (request.state.kind === 'pending') return copy.turnRequestPending;
+  if (request.state.kind === 'rejected') return copy.turnRequestRejected;
+  if (request.state.admission === 'pending') return copy.turnRequestApproved;
+  if (request.state.admission === 'started') return copy.turnRequestStarted;
+  if (request.state.admission === 'blocked') return copy.turnRequestBlocked;
+  return copy.turnRequestFailed;
+}
+
+function errorMessage(error: unknown): string {
+  return error instanceof Error ? error.message : String(error);
+}
diff --git a/apps/desktop/src/renderer/styles/settings/runtime-host.css b/apps/desktop/src/renderer/styles/settings/runtime-host.css
index a08d3847f..302e4e2cd 100644
--- a/apps/desktop/src/renderer/styles/settings/runtime-host.css
+++ b/apps/desktop/src/renderer/styles/settings/runtime-host.css
@@ -623,3 +623,23 @@
   align-items: center;
   gap: var(--space-1);
 }
+
+.sessionCollaborationTurnRequest {
+  display: grid;
+  grid-template-columns: minmax(0, 1fr) auto;
+  align-items: center;
+  gap: var(--space-3);
+  padding: var(--space-3);
+  border: 1px solid var(--border-subtle);
+  border-radius: var(--radius-lg);
+  background: var(--background-subtle);
+}
+
+.sessionCollaborationTurnRequest > div:first-child {
+  min-width: 0;
+  overflow-wrap: anywhere;
+}
+
+.sessionCollaborationTurnRequestText {
+  white-space: pre-wrap;
+}
diff --git a/apps/desktop/src/renderer/styles/shell-layout.css b/apps/desktop/src/renderer/styles/shell-layout.css
index 22938c1a3..9720272a2 100644
--- a/apps/desktop/src/renderer/styles/shell-layout.css
+++ b/apps/desktop/src/renderer/styles/shell-layout.css
@@ -581,3 +581,51 @@ dialog:modal {
   outline: none;
   box-shadow: inset 0 0 0 var(--focus-ring-width) var(--focus-ring);
 }
+
+.sessionTurnRequestSurface {
+  border-top: var(--border-width-hairline) solid var(--border);
+  background: var(--background);
+}
+
+.sessionTurnRequestComposer {
+  padding: var(--space-3);
+}
+
+.sessionCollaborationReadOnly {
+  padding: var(--space-3);
+  color: var(--foreground-secondary);
+  text-align: center;
+}
+
+.sessionTurnRequestHistory {
+  display: grid;
+  gap: var(--space-2);
+  padding: var(--space-3);
+  border-bottom: var(--border-width-hairline) solid var(--border);
+}
+
+.sessionTurnRequestHistoryRow {
+  display: grid;
+  grid-template-columns: minmax(0, 1fr) auto auto;
+  align-items: baseline;
+  gap: var(--space-3);
+}
+
+.sessionTurnRequestHistoryText {
+  min-width: 0;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.sessionTurnRequestComposer {
+  display: grid;
+  gap: var(--space-2);
+}
+
+.sessionTurnRequestComposerFooter {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: var(--space-3);
+}
diff --git a/apps/desktop/src/renderer/use-session-collaboration-dialog.ts b/apps/desktop/src/renderer/use-session-collaboration-dialog.ts
index 7bf81d764..27f727f2c 100644
--- a/apps/desktop/src/renderer/use-session-collaboration-dialog.ts
+++ b/apps/desktop/src/renderer/use-session-collaboration-dialog.ts
@@ -22,6 +22,7 @@ import { useState } from 'react';
 export interface SessionCollaborationDialogTarget {
   readonly sessionId: string;
   readonly sessionName: string;
+  readonly requiresRemoteAccess: boolean;
 }
 
 export function useSessionCollaborationDialog() {
diff --git a/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md
index 3bcd45c6b..e5b534da6 100644
--- a/docs/astryx-surface-file-inventory.md
+++ b/docs/astryx-surface-file-inventory.md
@@ -5,7 +5,7 @@ Each row is one on-disk product surface file. Regenerated inventory must stay in
 
 Wiki bar: Design Conventions · API Use-the-System · Theming · Container Padding.
 
-**Totals:** 224 files — blocker 0, polish 1, aligned 223.
+**Totals:** 225 files — blocker 0, polish 1, aligned 224.
 
 ## Exclusions (explicit)
 
@@ -71,7 +71,8 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi
 | `apps/desktop/src/renderer/plan-mode-panel.tsx` | shell-chrome-or-panel | Badge, Banner, Button, Collapsible | aligned — uses Astryx (Badge, Banner, Button, Collapsible) | aligned |
 | `apps/desktop/src/renderer/reference-shell.css` | styles | n/a (css) | aligned — no off-rhythm control heights flagged | aligned |
 | `apps/desktop/src/renderer/remote-project-directory-dialog.tsx` | dialog-overlay | Button, Dialog, DialogHeader, HStack, Layout, LayoutContent, Text | aligned — uses Astryx (Button, Dialog, DialogHeader, HStack, Layout, LayoutContent, Text) | aligned |
-| `apps/desktop/src/renderer/session-collaboration-dialog.tsx` | dialog-overlay | Button, Dialog, DialogHeader, Layout, LayoutContent | aligned — uses Astryx (Button, Dialog, DialogHeader, Layout, LayoutContent) | aligned |
+| `apps/desktop/src/renderer/session-collaboration-dialog.tsx` | dialog-overlay | Button, Dialog, DialogHeader, Layout, LayoutContent, SegmentedControl, SegmentedControlItem | aligned — uses Astryx (Button, Dialog, DialogHeader, Layout, LayoutContent, SegmentedControl, SegmentedControlItem) | aligned |
+| `apps/desktop/src/renderer/session-turn-request-composer.tsx` | shell-chrome-or-panel | Button | aligned — uses Astryx (Button) | aligned |
 | `apps/desktop/src/renderer/settings/about-settings-page.tsx` | settings-page | Badge, Banner, Button, List, ListItem | aligned — uses Astryx (Badge, Banner, Button, List, ListItem) | aligned |
 | `apps/desktop/src/renderer/settings/appearance-settings-page.tsx` | settings-page | Button, HStack, Text, VStack | aligned — uses Astryx (Button, HStack, Text, VStack) | aligned |
 | `apps/desktop/src/renderer/settings/bot-chat-detail.tsx` | settings-module | Banner, Button, Card, MetadataList, MetadataListItem, SegmentedControl, SegmentedControlItem, Selector, Text, VStack | aligned — uses Astryx (Banner, Button, Card, MetadataList, MetadataListItem, SegmentedControl, SegmentedControlItem, Selector) | aligned |
diff --git a/docs/astryx-surface-file-inventory.paths b/docs/astryx-surface-file-inventory.paths
index f9bccfbf8..ecf48a1a3 100644
--- a/docs/astryx-surface-file-inventory.paths
+++ b/docs/astryx-surface-file-inventory.paths
@@ -44,6 +44,7 @@ apps/desktop/src/renderer/plan-mode-panel.tsx
 apps/desktop/src/renderer/reference-shell.css
 apps/desktop/src/renderer/remote-project-directory-dialog.tsx
 apps/desktop/src/renderer/session-collaboration-dialog.tsx
+apps/desktop/src/renderer/session-turn-request-composer.tsx
 apps/desktop/src/renderer/settings/about-settings-page.tsx
 apps/desktop/src/renderer/settings/appearance-settings-page.tsx
 apps/desktop/src/renderer/settings/bot-chat-detail.tsx
diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts
index 651871f71..7d9f06ddf 100644
--- a/packages/runtime-host/src/protocol/index.ts
+++ b/packages/runtime-host/src/protocol/index.ts
@@ -95,7 +95,7 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const;
 export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const;
 // Increment when the same protocol version no longer guarantees safe Client-Host
 // interoperability. Mismatches are rejected before domain commands are admitted.
-export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 71 as const;
+export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 72 as const;
 // 71: Session Guests can submit durable exact Turn access requests and Owners
 // can decide them. Older peers do not understand this execution-authority flow.
 // 70: Session Guest connections receive resource-scoped shared catalog and
diff --git a/packages/runtime-host/src/protocol/session-collaboration.ts b/packages/runtime-host/src/protocol/session-collaboration.ts
index df0c4a377..cdfc55df0 100644
--- a/packages/runtime-host/src/protocol/session-collaboration.ts
+++ b/packages/runtime-host/src/protocol/session-collaboration.ts
@@ -140,6 +140,7 @@ export interface CollaborationTurnRequestQueryInput {
 }
 
 export interface CollaborationTurnRequestQueryResult {
+  readonly canRequestTurns: boolean;
   readonly requests: readonly SessionTurnAccessRequest[];
 }
 
@@ -430,11 +431,20 @@ function decodeCollaborationTurnRequestQueryInput(
 function decodeCollaborationTurnRequestQueryResult(
   value: unknown,
 ): CollaborationTurnRequestQueryResult {
-  const record = requireExactRecord(value, 'collaboration Turn request query result', ['requests']);
+  const record = requireExactRecord(value, 'collaboration Turn request query result', [
+    'canRequestTurns',
+    'requests',
+  ]);
   if (!Array.isArray(record.requests)) {
     throw invalidProtocolFrame('Invalid collaboration Turn request query result');
   }
-  return { requests: record.requests.map(decodeSessionTurnAccessRequest) };
+  if (typeof record.canRequestTurns !== 'boolean') {
+    throw invalidProtocolFrame('Invalid collaboration Turn request authority');
+  }
+  return {
+    canRequestTurns: record.canRequestTurns,
+    requests: record.requests.map(decodeSessionTurnAccessRequest),
+  };
 }
 
 function decodeCollaborationTurnRequestAcknowledgeInput(
diff --git a/packages/runtime-host/src/server/access-authority.ts b/packages/runtime-host/src/server/access-authority.ts
index 000a8868f..83e5f13d8 100644
--- a/packages/runtime-host/src/server/access-authority.ts
+++ b/packages/runtime-host/src/server/access-authority.ts
@@ -430,6 +430,10 @@ class FileRuntimeHostAccessAuthority implements RuntimeHostAccessAuthority {
   ): CollaborationTurnRequestQueryResult {
     const guest = principal.principalKind === 'session_guest';
     return {
+      canRequestTurns:
+        guest &&
+        this.activeSessionGrant(principal.principalId, input.sessionId, 'session_turn_request') !==
+          undefined,
       requests: this.#file.turnAccessRequests.filter(
         (request) =>
           (!guest || request.principalId === principal.principalId) &&


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


(maka) 02/04: feat(desktop): share Runtime Host sessions with guests (#4196)

Posted by wy...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

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

commit cfe87bac6e20d4b6dfdd11abd966da43ab506eaf
Author: Wang <m4...@gmail.com>
AuthorDate: Sun Aug 30 08:38:51 2026 +0800

    feat(desktop): share Runtime Host sessions with guests (#4196)
    
    Add explicit Guest profiles, informed one-time invitations, and a read-only shared Session surface without exposing Owner-only Host state.
    
    Generated-by: OpenAI Codex
---
 .../main/__tests__/runtime-host-client-uds.test.ts |   1 -
 .../runtime-host-collaboration-ipc-main.test.ts    |  92 ++++++
 .../runtime-host-desktop-candidate.test.ts         | 174 ++++++++++-
 .../__tests__/runtime-host-profile-service.test.ts |  98 ++++++
 ...time-host-session-catalog-running-turns.test.ts |  23 +-
 ...runtime-host-session-execution-ipc-main.test.ts |  11 +-
 .../runtime-host-session-observer.test.ts          |  10 +-
 .../src/main/runtime-host-artifacts-ipc-main.ts    | 104 ++++---
 apps/desktop/src/main/runtime-host-boot.ts         |   2 +
 apps/desktop/src/main/runtime-host-client.ts       |  30 ++
 .../main/runtime-host-collaboration-invitation.ts  | 103 +++++++
 .../main/runtime-host-collaboration-ipc-main.ts    |  88 ++++++
 .../src/main/runtime-host-desktop-candidate.ts     | 336 ++++++++++++++-------
 .../src/main/runtime-host-local-remote-access.ts   |  23 ++
 .../src/main/runtime-host-profile-service.ts       |  56 +++-
 .../main/runtime-host-session-catalog-ipc-main.ts  |  50 +++
 .../main/runtime-host-session-domains-ipc-main.ts  |  28 +-
 .../runtime-host-session-execution-ipc-main.ts     | 102 ++++---
 .../runtime-host-session-observation-registry.ts   |  31 ++
 .../runtime-host-session-subscription-owner.ts     |  26 +-
 .../src/main/runtime-host-shell-runs-ipc-main.ts   |  56 +++-
 apps/desktop/src/preload/bridge-contract.d.ts      |  39 +++
 apps/desktop/src/preload/preload.ts                |  40 ++-
 apps/desktop/src/renderer/app-shell-effects.ts     |   9 +-
 apps/desktop/src/renderer/app-shell.tsx            | 123 +++++---
 .../renderer/locales/session-collaboration-copy.ts |  98 ++++++
 .../src/renderer/session-collaboration-dialog.tsx  | 275 +++++++++++++++++
 .../settings/runtime-host-profiles-section.tsx     |  30 +-
 .../src/renderer/styles/settings/runtime-host.css  |  37 +++
 apps/desktop/src/renderer/styles/shell-layout.css  |   6 +
 .../renderer/use-session-collaboration-dialog.ts   |  35 +++
 apps/desktop/src/renderer/use-shell-memory-pill.ts |   8 +-
 .../src/shared/desktop-session-projection.ts       |   2 +
 docs/astryx-surface-file-inventory.md              |   5 +-
 docs/astryx-surface-file-inventory.paths           |   1 +
 .../src/__tests__/runtime-host-cli-context.test.ts |   4 +-
 .../__tests__/runtime-host-profile-command.test.ts |   4 +-
 .../src/__tests__/authenticated-websocket.test.ts  |   4 +
 .../src/__tests__/host-change-feed.test.ts         |  22 +-
 .../src/__tests__/host-profile.test.ts             |  12 +-
 packages/runtime-host/src/client/host-profile.ts   |  66 +++-
 packages/runtime-host/src/client/index.ts          |   2 +
 .../runtime-host/src/server/connection-session.ts  |  27 +-
 .../runtime-host/src/server/host-change-feed.ts    |  65 ++--
 packages/runtime-host/src/server/host-kernel.ts    |  18 ++
 packages/ui/src/icons.tsx                          |   1 +
 packages/ui/src/session-history-list.tsx           |   4 +-
 packages/ui/src/titlebar-session-identity.tsx      |  23 ++
 scripts/check-app-shell-hooks.mjs                  |   1 +
 49 files changed, 2054 insertions(+), 351 deletions(-)

diff --git a/apps/desktop/src/main/__tests__/runtime-host-client-uds.test.ts b/apps/desktop/src/main/__tests__/runtime-host-client-uds.test.ts
index 27f630c2f..f9e3819a7 100644
--- a/apps/desktop/src/main/__tests__/runtime-host-client-uds.test.ts
+++ b/apps/desktop/src/main/__tests__/runtime-host-client-uds.test.ts
@@ -388,7 +388,6 @@ test('drives the renderer Session execution facade through real UDS framing', as
       {
         client,
         observer,
-        observations: observer,
         attachmentApprovals: createAttachmentApprovalRegistry(),
         emitSessionsChanged() {},
         stat: async () => ({ size: 0 }),
diff --git a/apps/desktop/src/main/__tests__/runtime-host-collaboration-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-collaboration-ipc-main.test.ts
new file mode 100644
index 000000000..98061fddd
--- /dev/null
+++ b/apps/desktop/src/main/__tests__/runtime-host-collaboration-ipc-main.test.ts
@@ -0,0 +1,92 @@
+/*
+ * 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 assert from 'node:assert/strict';
+import test from 'node:test';
+import {
+  decodeCollaborationInvitationCode,
+  encodeCollaborationInvitationCode,
+} from '@maka/runtime-host/protocol';
+import type { IpcHandler, ReconnectableReadIpcMain } from '../ipc-reconnect-policy.js';
+import { decodeDesktopCollaborationInvitation } from '../runtime-host-collaboration-invitation.js';
+import { registerRuntimeHostCollaborationIpc } from '../runtime-host-collaboration-ipc-main.js';
+
+const ROOT_ID = 'a'.repeat(64);
+
+test('requires Owner confirmation before issuing a plaintext collaboration invitation', async () => {
+  const handlers = new Map<string, IpcHandler>();
+  const ipcMain: ReconnectableReadIpcMain = {
+    handle(channel, listener) {
+      handlers.set(channel, listener);
+    },
+  };
+  let prepareCalls = 0;
+  const client = {
+    async prepareCollaborationInvitation(sessionId: string, grantKinds: readonly string[]) {
+      prepareCalls += 1;
+      assert.equal(sessionId, 'session-1');
+      assert.deepEqual(grantKinds, ['session_observation']);
+      return {
+        invitationCode: encodeCollaborationInvitationCode({
+          schemaVersion: 1,
+          rootId: ROOT_ID,
+          credential: 'guest-token',
+        }),
+        principalId: 'guest-1',
+        expiresAt: '2026-08-31T00:00:00.000Z',
+        grants: [],
+      };
+    },
+    async queryCollaborationAccess() {
+      return { principals: [], grants: [] };
+    },
+    async revokeCollaborationPrincipal() {
+      return { revoked: false };
+    },
+  };
+  registerRuntimeHostCollaborationIpc(
+    client as unknown as Parameters<typeof registerRuntimeHostCollaborationIpc>[0],
+    ipcMain,
+    async () => ({
+      name: 'Lab',
+      transport: {
+        kind: 'plaintext',
+        url: 'ws://runtime.example.com',
+        acknowledgement: 'plaintext-bearer-v1',
+      },
+    }),
+  );
+  const prepare = handlers.get('session-collaboration:prepare');
+  assert.ok(prepare);
+
+  assert.deepEqual(await prepare({} as Parameters<IpcHandler>[0], 'session-1', false), {
+    kind: 'insecure_confirmation_required',
+  });
+  assert.equal(prepareCalls, 0);
+
+  const result = await prepare({} as Parameters<IpcHandler>[0], 'session-1', true);
+  assert.equal(prepareCalls, 1);
+  assert.equal((result as { kind?: unknown }).kind, 'prepared');
+  const invitation = (result as {
+    invitation: { invitationCode: string };
+  }).invitation;
+  const bundle = decodeDesktopCollaborationInvitation(invitation.invitationCode);
+  assert.equal(decodeCollaborationInvitationCode(bundle.invitationCode).rootId, ROOT_ID);
+  assert.equal(bundle.target.transport.kind, 'plaintext');
+});
diff --git a/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts b/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts
index 031c2b936..ae2ec73bb 100644
--- a/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts
+++ b/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts
@@ -23,6 +23,7 @@ import test from 'node:test';
 import type { IpcMain } from 'electron';
 import type { BotIncomingMessage, BotRegistry } from '@maka/runtime/bots';
 import type { ComputerUseToolSet } from '@maka/runtime/computer-use-tools';
+import type { ShellRunUpdate } from '@maka/core/events';
 import type { MakaTool } from '@maka/runtime/tool-runtime';
 import type {
   ClientCapabilityProvider,
@@ -183,6 +184,68 @@ test('owns one complete Desktop candidate generation and can restart cleanly', a
   assert.equal(ipc.size, 0);
 });
 
+test('registers only shared observation IPC and consumes scoped catalog changes for a Guest', async () => {
+  const ipc = ipcHarness();
+  const sharedResource = sharedShellRunUpdate('session-guest');
+  const host = connectionHarness('guest', { runtimeResourceUpdate: sharedResource });
+  const changes: Array<{ reason: string; sessionId?: string }> = [];
+  const rendererEvents: Array<{ channel: string; payload: unknown }> = [];
+  const candidate = await createCandidate(
+    host.connection,
+    {
+      ...deps(ipc),
+      emitSessionsChanged: (_scope, reason, sessionId) => {
+        changes.push({ reason, ...(sessionId === undefined ? {} : { sessionId }) });
+      },
+      renderer: {
+        send(channel, _scope, payload) {
+          rendererEvents.push({ channel, payload });
+        },
+      },
+    },
+    undefined,
+    'external',
+    'remote',
+    'session_guest',
+  );
+
+  assert.deepEqual(
+    ((await ipc.invoke('sessions:list')) as SessionCatalogProjection[]).map(({ id }) => id),
+    ['session-guest'],
+  );
+  assert.equal(ipc.channels.includes('sessions:observe'), true);
+  assert.equal(ipc.channels.includes('sessions:transcript:open'), true);
+  assert.equal(ipc.channels.includes('sessions:send'), false);
+  assert.equal(ipc.channels.includes('sessions:stop'), false);
+  assert.equal(ipc.channels.includes('tasks:list'), false);
+  assert.equal(ipc.channels.includes('attachments:readBytes'), true);
+  assert.deepEqual(await ipc.invoke('shell-runs:list', 'session-guest'), [sharedResource]);
+  assert.equal(ipc.channels.includes('shell-runs:attach'), false);
+  await ipc.invoke('sessions:observe', 'session-guest', 'guest-observer');
+  host.pushSubscriptionFrame({
+    kind: 'subscription.session_domain_changed',
+    hostEpoch: 'host-guest',
+    subscriptionId: 'subscription-guest',
+    sequence: 1,
+    sessionId: 'session-guest',
+    domain: 'runtime_resource',
+    resources: [
+      { sourceSessionId: 'session-guest', ref: sharedResource.result.ref },
+    ],
+  });
+  await waitFor(() =>
+    rendererEvents.some(
+      ({ channel, payload }) =>
+        channel === 'shell-runs:update' &&
+        (payload as ShellRunUpdate).result.ref === sharedResource.result.ref,
+    ),
+  );
+  host.publishSessionCatalogChange('session-guest');
+  assert.deepEqual(changes, [{ reason: 'updated', sessionId: 'session-guest' }]);
+
+  await candidate.close();
+});
+
 test('rejects a stale Host identity when raw Session IDs collide', async () => {
   const ipc = ipcHarness();
   const browserReleased: string[] = [];
@@ -778,6 +841,48 @@ test('retries candidate startup when a restored observation cannot seed', async
   await observations.close();
 });
 
+test('drops a stale shared Session observation when Guest access is gone', async () => {
+  const observations = new RuntimeHostSessionObservationRegistry();
+  const firstIpc = ipcHarness();
+  const firstHost = connectionHarness('shared-before-revoke', {
+    sessionId: 'session-1',
+    subscriptionSnapshot: continuitySnapshot(),
+  });
+  const firstCandidate = await createCandidate(
+    firstHost.connection,
+    deps(firstIpc),
+    observations,
+    'external',
+    'remote',
+    'session_guest',
+  );
+  await firstIpc.invoke('sessions:observe', 'session-1', 'observer-1');
+  await firstCandidate.close();
+
+  const changes: Array<{ reason: string; sessionId?: string }> = [];
+  const revokedHost = connectionHarness('shared-after-revoke', {
+    sharedSessionAvailable: false,
+  });
+  const candidate = await createCandidate(
+    revokedHost.connection,
+    {
+      ...deps(ipcHarness()),
+      emitSessionsChanged: (_scope, reason, sessionId) => {
+        changes.push({ reason, ...(sessionId === undefined ? {} : { sessionId }) });
+      },
+    },
+    observations,
+    'external',
+    'remote',
+    'session_guest',
+  );
+
+  assert.deepEqual(observations.trackedSessionIds(), []);
+  assert.deepEqual(changes, [{ reason: 'deleted', sessionId: 'session-1' }]);
+  await candidate.close();
+  await observations.close();
+});
+
 type IpcHandler = Parameters<Pick<IpcMain, 'handle'>['handle']>[1];
 
 function ipcHarness(onSend?: (channel: string, payload: unknown) => void) {
@@ -898,6 +1003,8 @@ function connectionHarness(
     activeAssistantStreams?: readonly SessionAssistantStreamIdentity[];
     subscriptionError?: Error;
     runtimeResourcePty?: ReturnType<typeof ptySnapshot>;
+    runtimeResourceUpdate?: ShellRunUpdate;
+    sharedSessionAvailable?: boolean;
   } = {},
 ) {
   let resolveClosed: (() => void) | undefined;
@@ -909,6 +1016,7 @@ function connectionHarness(
     resolveTurnStarted = resolve;
   });
   const closeSubscriptions = new Set<() => void>();
+  const sessionCatalogListeners = new Set<(frame: { sessionId: string }) => void>();
   let provider: ClientCapabilityProvider | undefined;
   let capabilityRegistrations = 0;
   let capabilityUnregistrations = 0;
@@ -934,6 +1042,21 @@ function connectionHarness(
           nextCursor: null,
         };
       }
+      if (operation === 'session.shared.query') {
+        if (options.sharedSessionAvailable === false) return { session: null };
+        const id = options.sessionId ?? `session-${label}`;
+        return {
+          session: {
+            kind: 'shared_session',
+            id,
+            revision: 1,
+            createdAt: 1,
+            activityAt: 1,
+            name: `Session ${label}`,
+            status: 'idle',
+          },
+        };
+      }
       if (operation === 'session.create') {
         return session((input as { sessionId: string }).sessionId);
       }
@@ -967,11 +1090,23 @@ function connectionHarness(
         };
       }
       if (operation === 'runtime.resource.query') {
+        const query = input as { kind: string; sessionId: string; ref?: string };
+        if (query.kind === 'get') {
+          return {
+            kind: 'resource',
+            sessionId: query.sessionId,
+            revision: catalogRevision(`${label}-resource`),
+            resource:
+              options.runtimeResourceUpdate?.result.ref === query.ref
+                ? options.runtimeResourceUpdate
+                : null,
+          };
+        }
         return {
           kind: 'page',
-          sessionId: (input as { sessionId: string }).sessionId,
+          sessionId: query.sessionId,
           revision: catalogRevision(`${label}-resource`),
-          resources: [],
+          resources: options.runtimeResourceUpdate ? [options.runtimeResourceUpdate] : [],
           nextCursor: null,
         };
       }
@@ -1052,6 +1187,10 @@ function connectionHarness(
       capabilityUnregistrations += 1;
       return { registrationId: `registration-${label}`, revision: 2 };
     },
+    subscribeSessionCatalogChanges: (listener: (frame: { sessionId: string }) => void) => {
+      sessionCatalogListeners.add(listener);
+      return () => sessionCatalogListeners.delete(listener);
+    },
     close: async () => {
       closeCalls += 1;
       for (const closeSubscription of closeSubscriptions) closeSubscription();
@@ -1074,6 +1213,9 @@ function connectionHarness(
       assert.ok(activeSubscriptionFrames);
       activeSubscriptionFrames.push(frame);
     },
+    publishSessionCatalogChange: (sessionId: string) => {
+      for (const listener of sessionCatalogListeners) listener({ sessionId });
+    },
     get capabilityRegistrations() {
       return capabilityRegistrations;
     },
@@ -1160,6 +1302,34 @@ function ptySnapshot(ref: string, buffer: string) {
   };
 }
 
+function sharedShellRunUpdate(sessionId: string): ShellRunUpdate {
+  return {
+    sessionId,
+    ownership: { kind: 'local' },
+    sourceTurnId: 'turn-shared',
+    sourceToolCallId: 'tool-shared',
+    result: {
+      kind: 'shell_run',
+      ref: 'maka://runtime/background-tasks/shared',
+      mode: 'pipes',
+      status: 'running',
+      cwd: '/workspace',
+      cmd: 'echo shared',
+      startedAt: 1,
+      updatedAt: 1,
+      revision: 1,
+      output: {
+        mode: 'pipes',
+        stdout: 'shared output',
+        stderr: '',
+        stdoutTruncated: false,
+        stderrTruncated: false,
+        redacted: false,
+      },
+    },
+  };
+}
+
 class AsyncFrameQueue implements AsyncIterable<SubscriptionFrame> {
   readonly #frames: SubscriptionFrame[] = [];
   readonly #waiters: Array<
diff --git a/apps/desktop/src/main/__tests__/runtime-host-profile-service.test.ts b/apps/desktop/src/main/__tests__/runtime-host-profile-service.test.ts
index 8af1443e7..83040e9f6 100644
--- a/apps/desktop/src/main/__tests__/runtime-host-profile-service.test.ts
+++ b/apps/desktop/src/main/__tests__/runtime-host-profile-service.test.ts
@@ -35,6 +35,7 @@ import {
   INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID,
   RUNTIME_HOST_COMPATIBILITY_EPOCH,
   RUNTIME_HOST_PROTOCOL_VERSION,
+  encodeCollaborationInvitationCode,
 } from "@maka/runtime-host/protocol";
 import {
   RuntimeHostPairingFinalizationInterruptedError,
@@ -49,6 +50,7 @@ import {
   createDesktopRuntimeHostProfileService,
   resolveDesktopRuntimeHostStartup,
 } from "../runtime-host-profile-service.js";
+import { encodeDesktopCollaborationInvitation } from '../runtime-host-collaboration-invitation.js';
 
 const ROOT_ID = "a".repeat(64);
 const PROFILE = {
@@ -501,6 +503,102 @@ test("keeps a separate profile when the same Host is paired through another conn
   assert.equal((await catalog.resolve("replacement")).credential, "new-token");
 });
 
+test('imports shared access without requiring or persisting an Owner credential', async () => {
+  const root = await clientRoot();
+  const catalog = createClientRuntimeHostProfileCatalog(root);
+  const startup = await resolveDesktopRuntimeHostStartup(root, { catalog });
+  const connected: ResolvedRuntimeHostProfile[] = [];
+  const finalized: string[] = [];
+  const service = createDesktopRuntimeHostProfileService({
+    clientDataRoot: root,
+    startup,
+    catalog,
+    states: () => [connectingLocal()],
+    enable: async (target) => {
+      connected.push(target);
+    },
+    disable: async () => undefined,
+    setDefault: () => undefined,
+    finalizePairing: async (profileId) => {
+      finalized.push(profileId);
+    },
+  });
+
+  const result = await service.importCollaborationInvitation(
+    encodeDesktopCollaborationInvitation({
+      invitationCode: encodeCollaborationInvitationCode({
+        schemaVersion: 1,
+        rootId: ROOT_ID,
+        credential: 'guest-token',
+      }),
+      target: {
+        name: PROFILE.name,
+        transport: PROFILE.transport,
+      },
+    }),
+    false,
+  );
+
+  assert.equal(result.kind, 'connected');
+  if (result.kind !== 'connected') return;
+  assert.equal((await catalog.read()).profiles.length, 1);
+  const sharedProfileId = connected[0]?.profile.id;
+  assert.ok(sharedProfileId);
+  const shared = await catalog.resolve(sharedProfileId);
+  assert.equal(shared.profile.kind, 'remote');
+  assert.equal(shared.profile.kind === 'remote' ? shared.profile.access : undefined, 'session_guest');
+  assert.equal(shared.credential, 'guest-token');
+  assert.deepEqual(finalized, [sharedProfileId]);
+});
+
+test('requires explicit confirmation before importing plaintext shared access', async () => {
+  const root = await clientRoot();
+  const catalog = createClientRuntimeHostProfileCatalog(root);
+  const startup = await resolveDesktopRuntimeHostStartup(root, { catalog });
+  const connected: ResolvedRuntimeHostProfile[] = [];
+  const service = createDesktopRuntimeHostProfileService({
+    clientDataRoot: root,
+    startup,
+    catalog,
+    states: () => [connectingLocal()],
+    enable: async (target) => {
+      connected.push(target);
+    },
+    disable: async () => undefined,
+    setDefault: () => undefined,
+    finalizePairing: async () => undefined,
+  });
+
+  const code = encodeDesktopCollaborationInvitation({
+    invitationCode: encodeCollaborationInvitationCode({
+      schemaVersion: 1,
+      rootId: ROOT_ID,
+      credential: 'guest-token',
+    }),
+    target: {
+      name: 'Lab',
+      transport: {
+        kind: 'plaintext',
+        url: 'ws://runtime.example.com',
+        acknowledgement: 'plaintext-bearer-v1',
+      },
+    },
+  });
+  assert.deepEqual(await service.importCollaborationInvitation(code, false), {
+    kind: 'error',
+    reason: 'insecure_confirmation_required',
+  });
+  assert.equal(connected.length, 0);
+
+  const result = await service.importCollaborationInvitation(code, true);
+  assert.equal(result.kind, 'connected');
+  assert.equal(connected[0]?.profile.kind, 'remote');
+  assert.equal(
+    connected[0]?.profile.kind === 'remote' ? connected[0].profile.transport.kind : undefined,
+    'plaintext',
+  );
+});
+
 test('classifies connection-code failures without exposing transport errors to the renderer', async () => {
   const root = await clientRoot();
   const startup = await resolveDesktopRuntimeHostStartup(root);
diff --git a/apps/desktop/src/main/__tests__/runtime-host-session-catalog-running-turns.test.ts b/apps/desktop/src/main/__tests__/runtime-host-session-catalog-running-turns.test.ts
index d2c3400c2..957a93007 100644
--- a/apps/desktop/src/main/__tests__/runtime-host-session-catalog-running-turns.test.ts
+++ b/apps/desktop/src/main/__tests__/runtime-host-session-catalog-running-turns.test.ts
@@ -21,7 +21,28 @@ import assert from 'node:assert/strict';
 import test from 'node:test';
 import type { SessionCatalogProjection } from '@maka/runtime-host/protocol';
 import type { IpcHandler } from '../ipc-reconnect-policy.js';
-import { registerRuntimeHostSessionCatalogIpc } from '../runtime-host-session-catalog-ipc-main.js';
+import {
+  registerRuntimeHostSessionCatalogIpc,
+  registerRuntimeHostSharedSessionCatalogIpc,
+} from '../runtime-host-session-catalog-ipc-main.js';
+
+test('registers a read-only Session catalog for shared access', async () => {
+  const handlers = new Map<string, IpcHandler>();
+  registerRuntimeHostSharedSessionCatalogIpc(
+    { getSession: async () => ({ id: 'shared' }) as never },
+    {
+      handle: (channel, listener) => handlers.set(channel, listener),
+      handleReconnectableRead: (channel, listener) => handlers.set(channel, listener),
+    },
+  );
+
+  assert.deepEqual([...handlers.keys()], ['sessions:list']);
+  assert.deepEqual(await handlers.get('sessions:list')!({} as never), [{ id: 'shared' }]);
+  assert.deepEqual(
+    await handlers.get('sessions:list')!({} as never, { subagentParentSessionId: 'parent' }),
+    [],
+  );
+});
 
 test('projects observed running Turn identities into renderer Session lists', async () => {
   const handlers = new Map<string, IpcHandler>();
diff --git a/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts
index 8bdf462be..711c22fb2 100644
--- a/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts
+++ b/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts
@@ -38,14 +38,22 @@ import { createAttachmentApprovalRegistry } from "../attachment-approval.js";
 import type { DesktopRuntimeHostSession } from "../runtime-host-client.js";
 import {
   registerRuntimeHostSessionExecutionIpc,
+  registerRuntimeHostSessionObservationIpc,
   type RuntimeHostSessionExecutionIpcDeps,
 } from "../runtime-host-session-execution-ipc-main.js";
+import { RuntimeHostSessionObservationRegistry } from '../runtime-host-session-observation-registry.js';
 import { RuntimeHostSessionObserver } from "../runtime-host-session-observer.js";
 import { runtimeHostSessionFixture } from "./runtime-host-session-test-fixture.js";
 
 test('registers Session observation as one reconnectable operation', () => {
   const ipc = ipcHarness();
-  registerExecutionIpc({ client: executionClient({}) }, ipc);
+  registerRuntimeHostSessionObservationIpc(
+    {
+      observations: new RuntimeHostSessionObservationRegistry(),
+      resolveSideConversation: async () => false,
+    },
+    ipc,
+  );
 
   assert.equal(ipc.reconnectableChannels.has('sessions:observe'), true);
 });
@@ -1681,7 +1689,6 @@ function registerExecutionIpc(
       resizeImage: async (bytes) => bytes,
       beforeStop() {},
       ...deps,
-      observations: deps.observations ?? observer,
       sessionCopyCleanup: deps.sessionCopyCleanup ?? unusedSessionCopyCleanup(),
       onBackgroundError: deps.onBackgroundError ?? (() => undefined),
     },
diff --git a/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts b/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts
index b8c7e90dc..4af7966a1 100644
--- a/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts
+++ b/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts
@@ -1631,9 +1631,10 @@ test("abandons a watched Turn when the initial Host subscription fails", async (
   await observer.close();
 });
 
-test("abandons a watched Turn when the Session is removed", async () => {
+test("abandons a watched Turn and removes it from the catalog when Guest access ends", async () => {
   const events = new AsyncFrameQueue();
   const finishedTurns: Array<[string, "completed" | "abandoned"]> = [];
+  const sessionChanges: string[] = [];
   let closeCount = 0;
   const observer = new RuntimeHostSessionObserver({
     client: {
@@ -1648,7 +1649,9 @@ test("abandons a watched Turn when the Session is removed", async () => {
         },
       }),
     },
-    emitSessionsChanged() {},
+    emitSessionsChanged(reason) {
+      sessionChanges.push(reason);
+    },
     onWatchedTurnFinished: (sessionId, outcome) => {
       finishedTurns.push([sessionId, outcome]);
     },
@@ -1660,11 +1663,12 @@ test("abandons a watched Turn when the Session is removed", async () => {
     hostEpoch: "host-1",
     subscriptionId: "subscription-1",
     sequence: 1,
-    reason: "session_removed",
+    reason: "access_revoked",
   });
 
   await waitFor(() => closeCount === 1);
   assert.deepEqual(finishedTurns, [["session-1", "abandoned"]]);
+  assert.deepEqual(sessionChanges, ["deleted"]);
   await observer.close();
 });
 
diff --git a/apps/desktop/src/main/runtime-host-artifacts-ipc-main.ts b/apps/desktop/src/main/runtime-host-artifacts-ipc-main.ts
index dc8ed7008..4032293ef 100644
--- a/apps/desktop/src/main/runtime-host-artifacts-ipc-main.ts
+++ b/apps/desktop/src/main/runtime-host-artifacts-ipc-main.ts
@@ -44,6 +44,11 @@ interface RuntimeHostArtifactsIpcDeps {
   readonly presentationRoot?: string;
 }
 
+type RuntimeHostAttachmentPreviewIpcDeps = Pick<
+  RuntimeHostArtifactsIpcDeps,
+  'ipcMain' | 'client'
+>;
+
 const ATTACHMENT_PREVIEW_LIMIT_EXCEEDED = Symbol("attachment-preview-limit-exceeded");
 
 export function registerRuntimeHostArtifactsIpc(
@@ -92,52 +97,7 @@ export function registerRuntimeHostArtifactsIpc(
       return result;
     },
   );
-  handleReconnectableRead(
-    deps.ipcMain,
-    "attachments:readBytes",
-    async (_event, sessionId: string, artifactId: string) => {
-      const artifact = await deps.client.getArtifact(sessionId, artifactId);
-      if (
-        !artifact ||
-        artifact.status === "deleted"
-      ) {
-        return { ok: false as const, reason: "not_found" };
-      }
-      const preview = resolveArtifactImagePreview(artifact);
-      if (preview.kind === "unsupported") {
-        return {
-          ok: false as const,
-          reason: preview.reason === "oversize" ? "too_large" : "unsupported_mime",
-        };
-      }
-      const mimeType = normalizeArtifactImagePreviewMime(artifact.mimeType, artifact.name);
-      if (!mimeType) return { ok: false as const, reason: "unsupported_mime" };
-      const chunks: Buffer[] = [];
-      let received = 0;
-      try {
-        await deps.client.streamArtifact(sessionId, artifactId, async (chunk) => {
-          received += chunk.byteLength;
-          if (received > ARTIFACT_IMAGE_PREVIEW_MAX_BYTES) {
-            throw ATTACHMENT_PREVIEW_LIMIT_EXCEEDED;
-          }
-          chunks.push(Buffer.from(chunk));
-        });
-      } catch (error) {
-        if (error === ATTACHMENT_PREVIEW_LIMIT_EXCEEDED) {
-          return { ok: false as const, reason: "too_large" };
-        }
-        throw error;
-      }
-      if (received !== artifact.sizeBytes) {
-        return { ok: false as const, reason: "read_failed" };
-      }
-      return {
-        ok: true as const,
-        base64: Buffer.concat(chunks, received).toString("base64"),
-        mimeType,
-      };
-    },
-  );
+  registerRuntimeHostAttachmentPreviewIpc(deps);
   deps.ipcMain.handle(
     "app:openArtifactPath",
     async (_event, sessionId: string, artifactId: string) => {
@@ -192,6 +152,58 @@ export function registerRuntimeHostArtifactsIpc(
   );
 }
 
+/** Guest-safe projection used by transcript attachment thumbnails. */
+export function registerRuntimeHostAttachmentPreviewIpc(
+  deps: RuntimeHostAttachmentPreviewIpcDeps,
+): void {
+  handleReconnectableRead(
+    deps.ipcMain,
+    "attachments:readBytes",
+    async (_event, sessionId: string, artifactId: string) => {
+      const artifact = await deps.client.getArtifact(sessionId, artifactId);
+      if (
+        !artifact ||
+        artifact.status === "deleted"
+      ) {
+        return { ok: false as const, reason: "not_found" };
+      }
+      const preview = resolveArtifactImagePreview(artifact);
+      if (preview.kind === "unsupported") {
+        return {
+          ok: false as const,
+          reason: preview.reason === "oversize" ? "too_large" : "unsupported_mime",
+        };
+      }
+      const mimeType = normalizeArtifactImagePreviewMime(artifact.mimeType, artifact.name);
+      if (!mimeType) return { ok: false as const, reason: "unsupported_mime" };
+      const chunks: Buffer[] = [];
+      let received = 0;
+      try {
+        await deps.client.streamArtifact(sessionId, artifactId, async (chunk) => {
+          received += chunk.byteLength;
+          if (received > ARTIFACT_IMAGE_PREVIEW_MAX_BYTES) {
+            throw ATTACHMENT_PREVIEW_LIMIT_EXCEEDED;
+          }
+          chunks.push(Buffer.from(chunk));
+        });
+      } catch (error) {
+        if (error === ATTACHMENT_PREVIEW_LIMIT_EXCEEDED) {
+          return { ok: false as const, reason: "too_large" };
+        }
+        throw error;
+      }
+      if (received !== artifact.sizeBytes) {
+        return { ok: false as const, reason: "read_failed" };
+      }
+      return {
+        ok: true as const,
+        base64: Buffer.concat(chunks, received).toString("base64"),
+        mimeType,
+      };
+    },
+  );
+}
+
 async function materializeArtifact(
   client: DesktopRuntimeHostClient,
   sessionId: string,
diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts
index e002cadeb..8212b199c 100644
--- a/apps/desktop/src/main/runtime-host-boot.ts
+++ b/apps/desktop/src/main/runtime-host-boot.ts
@@ -929,6 +929,8 @@ runtimeHostManager = await startRuntimeHostDesktopManager(
     registerClientIpc: registerHostClientIpc,
     openSshTunnel: runtimeHostSshTerminal.openSshTunnel,
     activateSshOperator: runtimeHostSshTerminal.activateSshOperator,
+    resolveLocalCollaborationConnectionTarget: () =>
+      localRuntimeHostRemoteAccess.createCollaborationConnectionTarget(),
   },
   {
     upgradePrompts: createRuntimeHostUpgradePrompts(
diff --git a/apps/desktop/src/main/runtime-host-client.ts b/apps/desktop/src/main/runtime-host-client.ts
index 22aaf6b55..74ef6579d 100644
--- a/apps/desktop/src/main/runtime-host-client.ts
+++ b/apps/desktop/src/main/runtime-host-client.ts
@@ -104,6 +104,11 @@ import {
   type ScheduledTaskChangedFrame,
   type SessionCatalogItem,
   type SessionCatalogProjection,
+  type SharedSessionCatalogProjection,
+  type CollaborationAccessQueryResult,
+  type CollaborationInvitationPrepareResult,
+  type CollaborationPrincipalRevokeResult,
+  type SessionCollaborationGrantKind,
   type SessionConfigurationPatch,
   type SessionAssistantStreamIdentity,
   type SessionContinuitySnapshot,
@@ -271,6 +276,26 @@ export class DesktopRuntimeHostClient {
     return this.request('access.credential.finalize', {}, timeoutMs);
   }
 
+  prepareCollaborationInvitation(
+    sessionId: string,
+    grantKinds: readonly SessionCollaborationGrantKind[],
+  ): Promise<CollaborationInvitationPrepareResult> {
+    return this.request('collaboration.invitation.prepare', { sessionId, grantKinds });
+  }
+
+  queryCollaborationAccess(sessionId?: string): Promise<CollaborationAccessQueryResult> {
+    return this.request(
+      'collaboration.access.query',
+      sessionId === undefined ? {} : { sessionId },
+    );
+  }
+
+  revokeCollaborationPrincipal(
+    principalId: string,
+  ): Promise<CollaborationPrincipalRevokeResult> {
+    return this.request('collaboration.principal.revoke', { principalId });
+  }
+
   subscribeConfigurationChanges(listener: (revision: number) => void): () => void {
     this.#assertOpen();
     return this.connection.subscribeConfigurationChanges(listener);
@@ -572,6 +597,11 @@ export class DesktopRuntimeHostClient {
     }
   }
 
+  async getSharedSession(): Promise<SharedSessionCatalogProjection | null> {
+    this.#assertOpen();
+    return (await this.request('session.shared.query', {})).session;
+  }
+
   async listProjects(
     includeLocations = true,
   ): Promise<(ProjectCatalogProject | ProjectCatalogProjectDetails)[]> {
diff --git a/apps/desktop/src/main/runtime-host-collaboration-invitation.ts b/apps/desktop/src/main/runtime-host-collaboration-invitation.ts
new file mode 100644
index 000000000..7b9890c4f
--- /dev/null
+++ b/apps/desktop/src/main/runtime-host-collaboration-invitation.ts
@@ -0,0 +1,103 @@
+/*
+ * 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 {
+  decodeRemoteRuntimeHostProfile,
+  type RuntimeHostRemoteTransport,
+} from '@maka/runtime-host/client';
+import { decodeCollaborationInvitationCode } from '@maka/runtime-host/protocol';
+
+const SCHEMA_VERSION = 1;
+const CODE_MAX_BYTES = 32 * 1024;
+
+export interface DesktopCollaborationConnectionTarget {
+  readonly name: string;
+  readonly transport: RuntimeHostRemoteTransport;
+}
+
+export interface DesktopCollaborationInvitation {
+  readonly invitationCode: string;
+  readonly target: DesktopCollaborationConnectionTarget;
+}
+
+export function encodeDesktopCollaborationInvitation(
+  value: DesktopCollaborationInvitation,
+): string {
+  const invitation = decodeCollaborationInvitationCode(value.invitationCode);
+  const target = decodeTarget(value.target, invitation.rootId);
+  return Buffer.from(
+    JSON.stringify({
+      schemaVersion: SCHEMA_VERSION,
+      invitationCode: value.invitationCode,
+      target,
+    }),
+    'utf8',
+  ).toString('base64url');
+}
+
+export function decodeDesktopCollaborationInvitation(
+  code: string,
+): DesktopCollaborationInvitation {
+  if (!code || Buffer.byteLength(code, 'utf8') > CODE_MAX_BYTES) {
+    throw new Error('Invalid Desktop collaboration invitation');
+  }
+  let value: unknown;
+  try {
+    value = JSON.parse(Buffer.from(code, 'base64url').toString('utf8')) as unknown;
+  } catch {
+    throw new Error('Invalid Desktop collaboration invitation');
+  }
+  if (!isRecord(value) || !hasExactKeys(value, ['schemaVersion', 'invitationCode', 'target'])) {
+    throw new Error('Invalid Desktop collaboration invitation');
+  }
+  if (value.schemaVersion !== SCHEMA_VERSION || typeof value.invitationCode !== 'string') {
+    throw new Error('Unsupported Desktop collaboration invitation');
+  }
+  const invitation = decodeCollaborationInvitationCode(value.invitationCode);
+  return {
+    invitationCode: value.invitationCode,
+    target: decodeTarget(value.target, invitation.rootId),
+  };
+}
+
+function decodeTarget(value: unknown, rootId: string): DesktopCollaborationConnectionTarget {
+  if (!isRecord(value) || !hasExactKeys(value, ['name', 'transport'])) {
+    throw new Error('Invalid Desktop collaboration connection target');
+  }
+  const profile = decodeRemoteRuntimeHostProfile({
+    id: 'collaboration-target',
+    name: value.name,
+    kind: 'remote',
+    rootId,
+    transport: value.transport,
+  });
+  return {
+    name: profile.name,
+    transport: profile.transport,
+  };
+}
+
+function isRecord(value: unknown): value is Record<string, unknown> {
+  return typeof value === 'object' && value !== null && !Array.isArray(value);
+}
+
+function hasExactKeys(value: Record<string, unknown>, expected: readonly string[]): boolean {
+  const keys = Object.keys(value).sort();
+  return keys.length === expected.length && expected.slice().sort().every((key, index) => key === keys[index]);
+}
diff --git a/apps/desktop/src/main/runtime-host-collaboration-ipc-main.ts b/apps/desktop/src/main/runtime-host-collaboration-ipc-main.ts
new file mode 100644
index 000000000..037749809
--- /dev/null
+++ b/apps/desktop/src/main/runtime-host-collaboration-ipc-main.ts
@@ -0,0 +1,88 @@
+/*
+ * 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 { DesktopRuntimeHostClient } from './runtime-host-client.js';
+import {
+  encodeDesktopCollaborationInvitation,
+  type DesktopCollaborationConnectionTarget,
+} from './runtime-host-collaboration-invitation.js';
+import {
+  handleReconnectableRead,
+  type ReconnectableReadIpcMain,
+} from './ipc-reconnect-policy.js';
+
+export function registerRuntimeHostCollaborationIpc(
+  client: Pick<
+    DesktopRuntimeHostClient,
+    | 'prepareCollaborationInvitation'
+    | 'queryCollaborationAccess'
+    | 'revokeCollaborationPrincipal'
+  >,
+  ipcMain: ReconnectableReadIpcMain,
+  resolveConnectionTarget: () =>
+    | DesktopCollaborationConnectionTarget
+    | Promise<DesktopCollaborationConnectionTarget>,
+): void {
+  ipcMain.handle(
+    'session-collaboration:prepare',
+    async (_event, sessionId: unknown, allowInsecure: unknown) => {
+      const target = await resolveConnectionTarget();
+      if (target.transport.kind === 'plaintext' && allowInsecure !== true) {
+        return { kind: 'insecure_confirmation_required' } as const;
+      }
+      const prepared = await client.prepareCollaborationInvitation(
+        requiredId(sessionId, 'Session'),
+        ['session_observation'],
+      );
+      return {
+        kind: 'prepared',
+        invitation: {
+          ...prepared,
+          invitationCode: encodeDesktopCollaborationInvitation({
+            invitationCode: prepared.invitationCode,
+            target,
+          }),
+        },
+      };
+    },
+  );
+  handleReconnectableRead(
+    ipcMain,
+    'session-collaboration:getAccess',
+    (_event, sessionId: unknown) =>
+      client.queryCollaborationAccess(requiredId(sessionId, 'Session')),
+  );
+  ipcMain.handle(
+    'session-collaboration:revokePrincipal',
+    (_event, principalId: unknown) =>
+      client.revokeCollaborationPrincipal(requiredId(principalId, 'Principal')),
+  );
+}
+
+function requiredId(value: unknown, label: string): string {
+  if (
+    typeof value !== 'string' ||
+    value.length === 0 ||
+    value.length > 512 ||
+    /[\u0000-\u001f\u007f]/u.test(value)
+  ) {
+    throw new Error(`Invalid ${label} identity`);
+  }
+  return value;
+}
diff --git a/apps/desktop/src/main/runtime-host-desktop-candidate.ts b/apps/desktop/src/main/runtime-host-desktop-candidate.ts
index 03ce20e79..51ff440b9 100644
--- a/apps/desktop/src/main/runtime-host-desktop-candidate.ts
+++ b/apps/desktop/src/main/runtime-host-desktop-candidate.ts
@@ -22,6 +22,7 @@ import type { IpcMain } from "electron";
 import type { ActiveInteractionRequestEvent } from '@maka/core/events';
 import { redactSecrets } from '@maka/core/redaction';
 import type { CreateSessionRequestInput } from '@maka/core/runtime-inputs';
+import { isSideConversationSession } from '@maka/core/side-conversation';
 import type { SessionChangedEvent, SessionChangedReason } from '@maka/core/session';
 import type { BotRegistry } from '@maka/runtime/bots';
 import {
@@ -38,6 +39,8 @@ import {
   type RuntimeHostCandidateLaunchBarrier,
   type RuntimeHostSpawnedProcess,
   type PersistedRuntimeHostProfile,
+  runtimeHostProfileAccess,
+  type RuntimeHostProfileAccess,
   type CandidateExitDetails,
 } from "@maka/runtime-host/client";
 import type { RuntimeHostActivationResult } from "@maka/runtime-host/operator";
@@ -66,16 +69,28 @@ import {
   type DesktopNativeCapabilityProvider,
   type DesktopNativeCapabilityProviderInput,
 } from "./runtime-host-native-capabilities.js";
-import { registerRuntimeHostSessionCatalogIpc } from "./runtime-host-session-catalog-ipc-main.js";
+import {
+  registerRuntimeHostSharedSessionCatalogIpc,
+  registerRuntimeHostSessionCatalogIpc,
+  toDesktopHostSharedSessionSummary,
+} from "./runtime-host-session-catalog-ipc-main.js";
 import { registerRuntimeHostWorkHubIpc } from "./runtime-host-workhub-ipc-main.js";
 import { registerRuntimeHostExternalSessionsIpc } from "./runtime-host-external-sessions-ipc-main.js";
+import { registerRuntimeHostCollaborationIpc } from './runtime-host-collaboration-ipc-main.js';
+import type { DesktopCollaborationConnectionTarget } from './runtime-host-collaboration-invitation.js';
+import { registerRuntimeHostAttachmentPreviewIpc } from './runtime-host-artifacts-ipc-main.js';
 import {
   registerRuntimeHostSessionDomainsIpc,
   type RuntimeHostSessionDomainsIpcDeps,
   type RuntimeHostSessionDomainsIpcHandle,
 } from "./runtime-host-session-domains-ipc-main.js";
+import {
+  registerRuntimeHostShellRunQueriesIpc,
+  type RuntimeHostShellRunQueriesIpcHandle,
+} from './runtime-host-shell-runs-ipc-main.js';
 import {
   registerRuntimeHostSessionExecutionIpc,
+  registerRuntimeHostSessionObservationIpc,
   type RuntimeHostSessionExecutionIpcDeps,
 } from "./runtime-host-session-execution-ipc-main.js";
 import { RuntimeHostSessionObservationRegistry } from "./runtime-host-session-observation-registry.js";
@@ -134,6 +149,8 @@ export interface DesktopRuntimeHostCandidateDeps {
   readonly activateSshOperator?: (
     input: RuntimeHostSshOperatorActivationInput,
   ) => Promise<RuntimeHostActivationResult>;
+  readonly resolveLocalCollaborationConnectionTarget?: () =>
+    Promise<DesktopCollaborationConnectionTarget>;
   readonly createSessionCopyCleanup: (input: {
     removeSession: (sessionId: string) => Promise<SessionCopyCleanupDisposition>;
     resumeSessionCopy: (input: {
@@ -157,6 +174,7 @@ export interface DesktopRuntimeHostCandidateDeps {
 export interface DesktopRuntimeHostTargetPolicy {
   readonly kind: RuntimeHostProfileKind;
   readonly rootId: string;
+  readonly access: RuntimeHostProfileAccess;
 }
 
 export interface DesktopRuntimeHostCandidateControls {
@@ -326,6 +344,7 @@ export async function startDesktopRuntimeHostCandidate(
           ? 'owned_ephemeral'
           : 'supervised',
         "local",
+        'owner',
         connection.registration.pid,
         connection.spawnedProcess,
       ),
@@ -336,6 +355,16 @@ export async function startDesktopRuntimeHostCandidate(
   }
 }
 
+function noGuestBotService(): BotIncomingMainService {
+  return {
+    handleBotIncomingMessage: async () => {
+      throw new Error('Session Guest profiles cannot receive bot messages');
+    },
+    invalidateSessionBindings: () => undefined,
+    close: async () => undefined,
+  };
+}
+
 function observeLocalRuntimeHostProcess(spawnedProcess: RuntimeHostSpawnedProcess | undefined): void {
   if (!spawnedProcess) return;
   void spawnedProcess.exited.then(
@@ -422,6 +451,15 @@ async function startProfileDesktopRuntimeHostCandidate(
         observationRegistry,
         'external',
         profileTarget.profile.kind,
+        runtimeHostProfileAccess(profileTarget.profile),
+        undefined,
+        undefined,
+        profileTarget.profile.kind === 'remote'
+          ? {
+              name: profileTarget.profile.name,
+              transport: profileTarget.profile.transport,
+            }
+          : undefined,
       ),
     };
   } catch (error) {
@@ -436,12 +474,15 @@ export async function createDesktopRuntimeHostCandidate(
   observationRegistry: RuntimeHostSessionObservationRegistry | undefined,
   hostOwnership: DesktopRuntimeHostOwnership,
   targetKind: DesktopRuntimeHostTargetPolicy["kind"],
+  targetAccess: RuntimeHostProfileAccess = 'owner',
   hostPid?: number,
   ownedProcess?: RuntimeHostSpawnedProcess,
+  collaborationConnectionTarget?: DesktopCollaborationConnectionTarget,
 ): Promise<DesktopRuntimeHostCandidate> {
   const target: DesktopRuntimeHostTargetPolicy = {
     kind: targetKind,
     rootId: connection.rootId,
+    access: targetAccess,
   };
   const ipcMain = deps.ipcMain;
   const scope = { hostId: connection.rootId, targetEpoch: ipcMain.epoch };
@@ -527,6 +568,7 @@ export async function createDesktopRuntimeHostCandidate(
   };
   let observer: RuntimeHostSessionObserver | undefined;
   let closeSessionDomains: (() => Promise<void>) | undefined;
+  let sharedShellRuns: RuntimeHostShellRunQueriesIpcHandle | undefined;
   let disposeClientIpc: (() => void | Promise<void>) | undefined;
   let observationsAttached = false;
   let capabilitiesRegistered = false;
@@ -545,39 +587,78 @@ export async function createDesktopRuntimeHostCandidate(
       client,
       emitSessionsChanged: (reason, sessionId, extra) =>
         emitSessionsChanged(reason, sessionId, extra),
-      emitSessionDomainChanged: (change) => domains?.sessionDomainChanged(change),
+      emitSessionDomainChanged: (change) =>
+        target.access === 'session_guest'
+          ? sharedShellRuns?.sessionDomainChanged(change)
+          : domains?.sessionDomainChanged(change),
       emitRuntimeResourcePtyData: (event) => domains?.runtimeResourcePtyData(event),
       emitAgentGraphChanged: (event) => domains?.agentGraphChanged(event),
       emitActiveInteractionsChanged,
       emitSubscriptionRecovered: (sessionId) =>
-        domains?.sessionSubscriptionRecovered(sessionId),
+        target.access === 'session_guest'
+          ? sharedShellRuns?.sessionSubscriptionRecovered(sessionId)
+          : domains?.sessionSubscriptionRecovered(sessionId),
       emitObservationSeed: (sessionId, phase) =>
         sendToRenderer?.('sessions:observation-seed', { sessionId, phase }),
-      onWatchedTurnFinished: (sessionId, outcome) =>
-        outcome === "completed"
-          ? deps.completeComputerUseTurn(
-              desktopSessionResourceKey({ ...scope, sessionId }),
-            )
-          : deps.nativeCapabilities.releaseComputerUseSession(
-              desktopSessionResourceKey({ ...scope, sessionId }),
-            ),
+      ...(target.access === 'owner'
+        ? {
+            onWatchedTurnFinished: (sessionId: string, outcome: 'completed' | 'abandoned') =>
+              outcome === 'completed'
+                ? deps.completeComputerUseTurn(
+                    desktopSessionResourceKey({ ...scope, sessionId }),
+                  )
+                : deps.nativeCapabilities.releaseComputerUseSession(
+                    desktopSessionResourceKey({ ...scope, sessionId }),
+                  ),
+          }
+        : {}),
       recoverConnectionClosed: observationRegistry !== undefined,
       ...(deps.now ? { now: deps.now } : {}),
     });
     observer = sessionObserver;
-    domains = registerRuntimeHostSessionDomainsIpc(
+    if (target.access === 'owner') {
+      domains = registerRuntimeHostSessionDomainsIpc(
+        {
+          client,
+          sessionObserver,
+          emitModeChanged,
+          ...(deps.renderer ? { sendToRenderer } : {}),
+          ...(deps.onError ? { onError: reportError } : {}),
+          ...(deps.newId ? { newId: deps.newId } : {}),
+          ...(deps.now ? { now: deps.now } : {}),
+        },
+        ipc,
+      );
+      closeSessionDomains = domains.close;
+    } else {
+      sharedShellRuns = registerRuntimeHostShellRunQueriesIpc(
+        { client, sendToRenderer, onError: reportError },
+        ipc,
+      );
+    }
+    registerRuntimeHostSessionObservationIpc(
       {
-        client,
-        sessionObserver,
-        emitModeChanged,
-        ...(deps.renderer ? { sendToRenderer } : {}),
-        ...(deps.onError ? { onError: reportError } : {}),
-        ...(deps.newId ? { newId: deps.newId } : {}),
-        ...(deps.now ? { now: deps.now } : {}),
+        observations: sessionObservations,
+        resolveSideConversation: async (sessionId) => {
+          if (target.access === 'session_guest') return false;
+          const session = await client.getSession(sessionId);
+          if (!session) throw new Error(`Runtime Host Session not found: ${sessionId}`);
+          return isSideConversationSession(session.labels);
+        },
       },
       ipc,
     );
-    closeSessionDomains = domains.close;
+    if (target.access === 'session_guest') {
+      const trackedSessionIds = sessionObservations.trackedSessionIds();
+      if (trackedSessionIds.length > 0) {
+        const sharedSessionId = (await client.getSharedSession())?.id;
+        for (const sessionId of trackedSessionIds) {
+          if (sessionId === sharedSessionId) continue;
+          await sessionObservations.forgetSession(sessionId);
+          emitSessionsChanged('deleted', sessionId);
+        }
+      }
+    }
     const observedSessionIds = sessionObservations.observedSessionIds();
     for (const sessionId of observedSessionIds) {
       sendToRenderer('sessions:observation-seed', { sessionId, phase: 'pending' });
@@ -610,7 +691,7 @@ export async function createDesktopRuntimeHostCandidate(
       sendToRenderer('sessions:observation-seed', { sessionId, phase: 'ready' });
       emitSessionsChanged("message-appended", sessionId);
       emitSessionsChanged("goal-change", sessionId);
-      domains.sessionSubscriptionRecovered(sessionId);
+      domains?.sessionSubscriptionRecovered(sessionId);
       emitActiveInteractionsChanged(
         sessionId,
         sessionObserver.listActiveInteractions(sessionId) ?? [],
@@ -642,13 +723,15 @@ export async function createDesktopRuntimeHostCandidate(
       providers.add(provider);
       return provider;
     };
-    const nativeCapabilities = createNativeProvider();
-    if (
-      nativeCapabilities.offers().length > 0 ||
-      (nativeCapabilities.services?.().length ?? 0) > 0
-    ) {
-      await client.replaceClientCapabilities(nativeCapabilities);
-      capabilitiesRegistered = true;
+    if (target.access === 'owner') {
+      const nativeCapabilities = createNativeProvider();
+      if (
+        nativeCapabilities.offers().length > 0 ||
+        (nativeCapabilities.services?.().length ?? 0) > 0
+      ) {
+        await client.replaceClientCapabilities(nativeCapabilities);
+        capabilitiesRegistered = true;
+      }
     }
     let capabilityRefresh = Promise.resolve();
     const refreshClientCapabilities = (): Promise<void> => {
@@ -666,96 +749,131 @@ export async function createDesktopRuntimeHostCandidate(
         });
       return capabilityRefresh;
     };
-    const sessionCopyCleanup = deps.createSessionCopyCleanup({
-      removeSession: async (sessionId) => {
-        const disposition = await client.removeSessionCopy(sessionId);
-        if (disposition === "retained") return disposition;
-        await releaseNativeSession(sessionId).catch(reportError);
-        emitSessionsChanged("deleted", sessionId);
-        return disposition;
-      },
-      resumeSessionCopy: async ({ sessionId, kind, sourceSessionId, sourceTurnId, intent }) => {
-        await client.copySession(kind, {
-          sourceSessionId,
-          targetSessionId: sessionId,
-          sourceTurnId,
-          ...(intent ? { intent } : {}),
-        });
-      },
-    });
-    const registeredClientIpc = deps.registerClientIpc?.(
-      client,
-      ipc,
-      { refreshClientCapabilities },
-      target,
-      scope,
-      isTargetActive,
-    );
-    disposeClientIpc =
-      typeof registeredClientIpc === "function"
+    const sessionCopyCleanup = target.access === 'owner'
+      ? deps.createSessionCopyCleanup({
+          removeSession: async (sessionId) => {
+            const disposition = await client.removeSessionCopy(sessionId);
+            if (disposition === "retained") return disposition;
+            await releaseNativeSession(sessionId).catch(reportError);
+            emitSessionsChanged("deleted", sessionId);
+            return disposition;
+          },
+          resumeSessionCopy: async ({ sessionId, kind, sourceSessionId, sourceTurnId, intent }) => {
+            await client.copySession(kind, {
+              sourceSessionId,
+              targetSessionId: sessionId,
+              sourceTurnId,
+              ...(intent ? { intent } : {}),
+            });
+          },
+        })
+      : undefined;
+    const registeredClientIpc = target.access === 'owner'
+      ? deps.registerClientIpc?.(
+          client,
+          ipc,
+          { refreshClientCapabilities },
+          target,
+          scope,
+          isTargetActive,
+        )
+      : undefined;
+    disposeClientIpc = target.access === 'session_guest'
+      ? client.subscribeSessionCatalogChanges(({ sessionId }) =>
+          emitSessionsChanged('updated', sessionId),
+        )
+      : typeof registeredClientIpc === 'function'
         ? registeredClientIpc
         : undefined;
-    registerRuntimeHostSessionCatalogIpc(
-      {
-        client,
-        runningTurnIds: (sessionId) => sessionObserver.observedRunningTurnIds(sessionId),
-        resolveCreateProject: (input) => deps.resolveSessionCreateProject(input, target),
-        emitSessionsChanged,
-        releaseSessionResources: releaseNativeSession,
-        sessionCopyCleanup,
-        ...(deps.newId ? { newId: deps.newId } : {}),
-      },
-      ipc,
-    );
-    registerRuntimeHostWorkHubIpc(client, ipc, {
-      resolveCreateProject: () => deps.resolveSessionCreateProject({}, target),
-      emitSessionsChanged,
-    });
-    registerRuntimeHostExternalSessionsIpc(
-      {
-        client,
-        emitSessionsChanged,
-      },
-      ipc,
-    );
-    const stopSession = registerRuntimeHostSessionExecutionIpc(
-      {
-        client,
-        observer: sessionObserver,
-        observations: sessionObservations,
-        attachmentApprovals: deps.attachmentApprovals,
-        emitSessionsChanged,
-        stat: deps.stat,
-        resizeImage: deps.resizeImage,
-        beforeStop: (sessionId) =>
-          deps.nativeCapabilities.releaseComputerUseSession(
-            desktopSessionResourceKey({ ...scope, sessionId }),
-          ),
-        sessionCopyCleanup,
-        onBackgroundError: reportError,
-        ...(deps.e2eInteractions
-          ? { e2eInteractions: deps.e2eInteractions }
-          : {}),
-        ...(deps.newId ? { newId: deps.newId } : {}),
-      },
-      ipc,
-    );
-    const botIncoming = createBotIncomingMainService({
-      botRegistry: deps.botRegistry,
-      sessions: createRuntimeHostBotSessionAdapter({
-        client,
-        resolveCreateTarget: () => deps.resolveBotCreateTarget(target),
+    if (target.access === 'session_guest') {
+      registerRuntimeHostAttachmentPreviewIpc({ ipcMain: ipc, client });
+      registerRuntimeHostSharedSessionCatalogIpc(
+        {
+          getSession: async () => {
+            const session = await client.getSharedSession();
+            return session ? toDesktopHostSharedSessionSummary(session) : null;
+          },
+        },
+        ipc,
+      );
+    } else {
+      if (!sessionCopyCleanup) throw new Error('Owner Session copy authority is unavailable');
+      registerRuntimeHostSessionCatalogIpc(
+        {
+          client,
+          runningTurnIds: (sessionId) => sessionObserver.observedRunningTurnIds(sessionId),
+          resolveCreateProject: (input) => deps.resolveSessionCreateProject(input, target),
+          emitSessionsChanged,
+          releaseSessionResources: releaseNativeSession,
+          sessionCopyCleanup,
+          ...(deps.newId ? { newId: deps.newId } : {}),
+        },
+        ipc,
+      );
+    }
+    if (target.access === 'owner') {
+      registerRuntimeHostCollaborationIpc(client, ipc, async () => {
+        if (collaborationConnectionTarget) return collaborationConnectionTarget;
+        if (target.kind === 'local' && deps.resolveLocalCollaborationConnectionTarget) {
+          return deps.resolveLocalCollaborationConnectionTarget();
+        }
+        throw new Error('This Runtime Host does not have a shareable connection target');
+      });
+      registerRuntimeHostWorkHubIpc(client, ipc, {
+        resolveCreateProject: () => deps.resolveSessionCreateProject({}, target),
         emitSessionsChanged,
-        ...(deps.newId ? { newId: deps.newId } : {}),
-      }),
-    });
+      });
+      registerRuntimeHostExternalSessionsIpc(
+        {
+          client,
+          emitSessionsChanged,
+        },
+        ipc,
+      );
+    }
+    const stopSession = sessionCopyCleanup
+      ? registerRuntimeHostSessionExecutionIpc(
+          {
+            client,
+            observer: sessionObserver,
+            attachmentApprovals: deps.attachmentApprovals,
+            emitSessionsChanged,
+            stat: deps.stat,
+            resizeImage: deps.resizeImage,
+            beforeStop: (sessionId) =>
+              deps.nativeCapabilities.releaseComputerUseSession(
+                desktopSessionResourceKey({ ...scope, sessionId }),
+              ),
+            sessionCopyCleanup,
+            onBackgroundError: reportError,
+            ...(deps.e2eInteractions
+              ? { e2eInteractions: deps.e2eInteractions }
+              : {}),
+            ...(deps.newId ? { newId: deps.newId } : {}),
+          },
+          ipc,
+        )
+      : async () => {
+          throw new Error('Shared Sessions cannot be stopped');
+        };
+    const botIncoming = target.access === 'owner'
+      ? createBotIncomingMainService({
+          botRegistry: deps.botRegistry,
+          sessions: createRuntimeHostBotSessionAdapter({
+            client,
+            resolveCreateTarget: () => deps.resolveBotCreateTarget(target),
+            emitSessionsChanged,
+            ...(deps.newId ? { newId: deps.newId } : {}),
+          }),
+        })
+      : noGuestBotService();
     return new DesktopRuntimeHostCandidateImpl({
       client,
       observer: sessionObserver,
       ipc,
       botIncoming,
       closeNativeCapabilities,
-      closeSessionDomains: domains.close,
+      closeSessionDomains: domains?.close ?? (() => Promise.resolve()),
       disposeClientIpc,
       detachSessionObservations: () =>
         sessionObservations.detach(sessionObserver),
diff --git a/apps/desktop/src/main/runtime-host-local-remote-access.ts b/apps/desktop/src/main/runtime-host-local-remote-access.ts
index 49bc2b76a..01e084c27 100644
--- a/apps/desktop/src/main/runtime-host-local-remote-access.ts
+++ b/apps/desktop/src/main/runtime-host-local-remote-access.ts
@@ -95,6 +95,15 @@ export interface DesktopRuntimeHostLocalManagementTarget
 
 export interface DesktopLocalRuntimeHostRemoteAccess {
   getSnapshot(): Promise<DesktopLocalRuntimeHostRemoteAccessSnapshot>;
+  createCollaborationConnectionTarget(): Promise<{
+    readonly name: string;
+    readonly transport: {
+      readonly kind: 'libp2p-direct';
+      readonly peerId: string;
+      readonly routeHints: readonly string[];
+      readonly coordinationRelays: readonly string[];
+    };
+  }>;
   enable(value: unknown): Promise<DesktopLocalRuntimeHostRemoteAccessEnableResult>;
   disable(): Promise<DesktopLocalRuntimeHostRemoteAccessSnapshot>;
   uninstall(value: unknown): Promise<{ readonly kind: 'active_tasks' | 'uninstalled' }>;
@@ -489,6 +498,19 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: {
       return issueConnectionCode(input.rootPath, managed.rootId, peer, localClient(input.manager));
     });
 
+  const createCollaborationConnectionTarget = () =>
+    serialize(async () => {
+      const managed = requireManaged(
+        await readLifecycle(lifecyclePath, input.rootPath, input.rootId),
+      );
+      const peer = await readPeer(input.operator, managed);
+      if (!peer) throw new Error('Remote access is not enabled on this computer');
+      return {
+        name: hostName(),
+        transport: { kind: 'libp2p-direct' as const, ...peer },
+      };
+    });
+
   const revokeSharedAccess = (): Promise<DesktopLocalRuntimeHostRemoteAccessSnapshot> =>
     serialize(async () => {
       const managed = requireManaged(
@@ -653,6 +675,7 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: {
 
   return {
     getSnapshot,
+    createCollaborationConnectionTarget,
     enable,
     disable,
     uninstall,
diff --git a/apps/desktop/src/main/runtime-host-profile-service.ts b/apps/desktop/src/main/runtime-host-profile-service.ts
index 1747d1343..dd91bf7eb 100644
--- a/apps/desktop/src/main/runtime-host-profile-service.ts
+++ b/apps/desktop/src/main/runtime-host-profile-service.ts
@@ -37,6 +37,7 @@ import {
   type RuntimeHostProfileCatalog,
 } from "@maka/runtime-host/client";
 import { runtimeHostAccessCredentialFingerprint } from "@maka/runtime-host/operator";
+import { decodeCollaborationInvitationCode } from '@maka/runtime-host/protocol';
 import type { CredentialStore } from "@maka/storage/credential-store";
 import { withFileUpdateLock } from "@maka/storage/file-update-lock";
 import type {
@@ -45,6 +46,7 @@ import type {
   DesktopRuntimeHostProfileEntry,
   DesktopRuntimeHostProfileSnapshot,
   DesktopRuntimeHostConnectionCodeImportResult,
+  DesktopSessionCollaborationImportResult,
 } from "../preload/bridge-contract.js";
 import {
   RuntimeHostPairingFinalizationInterruptedError,
@@ -66,6 +68,7 @@ import {
   type DesktopRuntimeHostManagedServiceBinding,
   type DesktopRuntimeHostManagedServiceStore,
 } from "./runtime-host-managed-services.js";
+import { decodeDesktopCollaborationInvitation } from './runtime-host-collaboration-invitation.js';
 
 const PREFERENCES_SCHEMA_VERSION = 2;
 const PREFERENCES_FILE = "runtime-host-profile-selection.json";
@@ -99,6 +102,10 @@ export interface DesktopRuntimeHostProfileService {
     },
   ): Promise<{ readonly profileId: string }>;
   importConnectionCode(code: string): Promise<DesktopRuntimeHostConnectionCodeImportResult>;
+  importCollaborationInvitation(
+    code: string,
+    allowInsecure: boolean,
+  ): Promise<DesktopSessionCollaborationImportResult>;
   resolveManagedService(
     profileId: string,
   ): Promise<DesktopRuntimeHostManagedServiceBinding | undefined>;
@@ -203,9 +210,13 @@ export async function resolveDesktopRuntimeHostStartup(
     };
   }
   const profileIds = new Set(document.profiles.map((profile) => profile.id));
+  const defaultProfile = document.profiles.find(
+    (profile) => profile.id === preferences.defaultProfileId,
+  );
   const defaultProfileId =
     preferences.defaultProfileId === LOCAL_RUNTIME_HOST_PROFILE.id ||
-    profileIds.has(preferences.defaultProfileId)
+    (profileIds.has(preferences.defaultProfileId) &&
+      !(defaultProfile?.kind === 'remote' && defaultProfile.access === 'session_guest'))
       ? preferences.defaultProfileId
       : LOCAL_RUNTIME_HOST_PROFILE.id;
   const enabledRemoteProfileIds = new Set(
@@ -764,6 +775,39 @@ export function createDesktopRuntimeHostProfileService(input: {
         return { kind: 'error', reason: connectionCodeImportFailure(error) };
       }
     },
+    async importCollaborationInvitation(code, allowInsecure) {
+      let bundle;
+      let invitation;
+      try {
+        bundle = decodeDesktopCollaborationInvitation(code);
+        invitation = decodeCollaborationInvitationCode(bundle.invitationCode);
+      } catch {
+        return { kind: 'error', reason: 'invalid_code' };
+      }
+      if (bundle.target.transport.kind === 'plaintext' && !allowInsecure) {
+        return { kind: 'error', reason: 'insecure_confirmation_required' };
+      }
+      try {
+        await addAndEnableVerified({
+          profile: {
+            id: `shared-${randomUUID()}`,
+            name: `${bundle.target.name} · Shared`,
+            kind: 'remote',
+            rootId: invitation.rootId,
+            transport: bundle.target.transport,
+            access: 'session_guest',
+          },
+          credential: invitation.credential,
+        });
+        return { kind: 'connected' };
+      } catch (error) {
+        return {
+          kind: 'error',
+          reason: 'connection_failed',
+          message: asError(error).message,
+        };
+      }
+    },
     rotateManagedCredential(expected, credential) {
       return mutateProfiles(async () => {
         const profileId = expected.profile.id;
@@ -1099,6 +1143,12 @@ export function createDesktopRuntimeHostProfileService(input: {
         ) {
           throw new Error("Enable a Runtime Host before making it the default");
         }
+        if (profileId !== LOCAL_RUNTIME_HOST_PROFILE.id) {
+          const target = await catalog.resolve(profileId);
+          if (target.profile.kind === 'remote' && target.profile.access === 'session_guest') {
+            throw new Error('A shared Session connection cannot be the default Runtime Host');
+          }
+        }
         const next = { ...preferences, defaultProfileId: profileId };
         await persist(next);
         input.setDefault(profileId);
@@ -1251,6 +1301,7 @@ export function registerDesktopRuntimeHostProfileIpc(
     "runtime-host-profiles:set-default",
     "runtime-host-profiles:remove",
     "runtime-host-profiles:resolve-pairing-recovery",
+    'session-collaboration:import',
   ] as const;
   ipcMain.handle(channels[0], () => service.getSnapshot());
   ipcMain.handle(channels[1], (_event, value: DesktopRuntimeHostProfileAddInput) =>
@@ -1263,6 +1314,9 @@ export function registerDesktopRuntimeHostProfileIpc(
   ipcMain.handle(channels[4], (_event, profileId: string) => service.setDefault(profileId));
   ipcMain.handle(channels[5], (_event, profileId: string) => service.remove(profileId));
   ipcMain.handle(channels[6], () => service.resolvePairingRecovery());
+  ipcMain.handle(channels[7], (_event, code: string, allowInsecure: boolean) =>
+    service.importCollaborationInvitation(code, allowInsecure),
+  );
   return () => {
     for (const channel of channels) ipcMain.removeHandler(channel);
   };
diff --git a/apps/desktop/src/main/runtime-host-session-catalog-ipc-main.ts b/apps/desktop/src/main/runtime-host-session-catalog-ipc-main.ts
index 1c9325ec0..4abdaa5ff 100644
--- a/apps/desktop/src/main/runtime-host-session-catalog-ipc-main.ts
+++ b/apps/desktop/src/main/runtime-host-session-catalog-ipc-main.ts
@@ -27,6 +27,7 @@ import { type SessionChangedEvent, type SessionChangedReason, type SessionCatalo
 import { projectSessionCatalogSummary } from '@maka/runtime-host/client';
 import type {
   SessionCatalogProjection,
+  SharedSessionCatalogProjection,
   SessionCreateInput,
   WorkspaceTarget,
   SessionModelTarget,
@@ -59,6 +60,7 @@ type RuntimeHostSessionCatalogClient = Pick<
 
 export interface DesktopHostSessionSummary extends SessionCatalogSummary {
   labelsTruncated: boolean;
+  shared?: true;
 }
 
 export interface RuntimeHostSessionCatalogIpcDeps {
@@ -78,6 +80,10 @@ export interface RuntimeHostSessionCatalogIpcDeps {
   newId?: () => string;
 }
 
+export interface RuntimeHostSharedSessionCatalogIpcDeps {
+  getSession(): Promise<DesktopHostSessionSummary | null>;
+}
+
 export function registerRuntimeHostSessionCatalogIpc(
   deps: RuntimeHostSessionCatalogIpcDeps,
   ipcMain: ReconnectableReadIpcMain,
@@ -221,6 +227,50 @@ export function registerRuntimeHostSessionCatalogIpc(
   });
 }
 
+export function registerRuntimeHostSharedSessionCatalogIpc(
+  deps: RuntimeHostSharedSessionCatalogIpcDeps,
+  ipcMain: ReconnectableReadIpcMain,
+): void {
+  handleReconnectableRead(ipcMain, 'sessions:list', async (_event, filter?: unknown) => {
+    if (normalizeSessionListFilter(filter)?.subagentParentSessionId) return [];
+    const session = await deps.getSession();
+    return session ? [session] : [];
+  });
+}
+
+export function toDesktopHostSharedSessionSummary(
+  session: SharedSessionCatalogProjection,
+): DesktopHostSessionSummary {
+  return {
+    id: session.id,
+    name: session.name,
+    activityAt: session.activityAt,
+    isFlagged: false,
+    isArchived: false,
+    labels: [],
+    labelsTruncated: false,
+    hasUnread: false,
+    ...(session.lastMessageAt === undefined ? {} : { lastMessageAt: session.lastMessageAt }),
+    ...(session.lastMessagePreview === undefined
+      ? {}
+      : { lastMessagePreview: session.lastMessagePreview }),
+    status: session.status,
+    ...(session.liveRunState === undefined
+      ? {}
+      : { runningTurnIds: [...session.liveRunState.runningTurnIds] }),
+    ...(session.blockedReason === undefined ? {} : { blockedReason: session.blockedReason }),
+    ...(session.statusUpdatedAt === undefined
+      ? {}
+      : { statusUpdatedAt: session.statusUpdatedAt }),
+    backend: 'ai-sdk',
+    llmConnectionSlug: '',
+    connectionLocked: true,
+    model: '',
+    permissionMode: 'ask',
+    shared: true,
+  };
+}
+
 /**
  * Reads the archived premise off the remove options.
  *
diff --git a/apps/desktop/src/main/runtime-host-session-domains-ipc-main.ts b/apps/desktop/src/main/runtime-host-session-domains-ipc-main.ts
index c0fd5aaa3..e08afc239 100644
--- a/apps/desktop/src/main/runtime-host-session-domains-ipc-main.ts
+++ b/apps/desktop/src/main/runtime-host-session-domains-ipc-main.ts
@@ -46,6 +46,7 @@ import {
   type ReconnectableReadIpcMain,
 } from './ipc-reconnect-policy.js';
 import {
+  registerRuntimeHostShellRunQueriesIpc,
   registerRuntimeHostShellRunsIpc,
   type RuntimeHostShellRunsClient,
 } from './runtime-host-shell-runs-ipc-main.js';
@@ -104,6 +105,14 @@ export function registerRuntimeHostSessionDomainsIpc(
     { client: deps.client, newId, sessionObserver: deps.sessionObserver },
     ipcMain,
   );
+  const shellRunQueries = registerRuntimeHostShellRunQueriesIpc(
+    {
+      client: deps.client,
+      sendToRenderer: deps.sendToRenderer,
+      onError: deps.onError,
+    },
+    ipcMain,
+  );
 
   handleReconnectableRead(ipcMain, 'tasks:list', (_event, sessionId: unknown) =>
     deps.client.listTasks(requiredId(sessionId, 'Session')),
@@ -340,7 +349,7 @@ export function registerRuntimeHostSessionDomainsIpc(
         deps.sendToRenderer?.('usage:changed', { sessionId: change.sessionId });
         break;
       case 'runtime_resource':
-        void refreshRuntimeResources(deps, change.sessionId, change.resources);
+        shellRunQueries.sessionDomainChanged(change);
         break;
     }
   };
@@ -359,27 +368,12 @@ export function registerRuntimeHostSessionDomainsIpc(
       sessionDomainChanged({ sessionId, domain: 'plan' });
       sessionDomainChanged({ sessionId, domain: 'usage' });
       deps.sendToRenderer?.('graphs:resync', { rootSessionId: sessionId });
-      deps.sendToRenderer?.('shell-runs:resync', { sessionId });
+      shellRunQueries.sessionSubscriptionRecovered(sessionId);
     },
     close: () => shellRuns.close(),
   };
 }
 
-async function refreshRuntimeResources(
-  deps: RuntimeHostSessionDomainsIpcDeps,
-  sessionId: string,
-  resources: readonly { ref: string }[],
-): Promise<void> {
-  for (const resource of resources) {
-    try {
-      const update = await deps.client.getRuntimeResource(sessionId, resource.ref);
-      if (update) deps.sendToRenderer?.('shell-runs:update', update);
-    } catch (error) {
-      deps.onError?.(error);
-    }
-  }
-}
-
 interface CanonicalGoalArmRequest {
   readonly sessionId: string;
   readonly condition: string;
diff --git a/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts b/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts
index 51e656253..9e7139044 100644
--- a/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts
+++ b/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts
@@ -20,11 +20,11 @@
 import { randomUUID } from "node:crypto";
 import type { IpcMainInvokeEvent } from "electron";
 import { MAX_ATTACHMENT_COUNT } from '@maka/core/attachments';
+import { isSideConversationSession } from '@maka/core/side-conversation';
 import {
   RuntimeHostOperationError,
   RuntimeHostRequestInterruptedError,
 } from '@maka/runtime-host/client';
-import { isSideConversationSession } from '@maka/core/side-conversation';
 import {
   type SessionChangedEvent,
   type SessionChangedReason,
@@ -145,13 +145,6 @@ async function submitMessageWithReconnect(
 export interface RuntimeHostSessionExecutionIpcDeps {
   client: RuntimeHostSessionExecutionClient;
   observer: RuntimeHostSessionObserver;
-  observations: Pick<
-    RuntimeHostSessionObservationRegistry,
-    | 'loadTranscriptAround'
-    | 'loadTranscriptBefore'
-    | 'observe'
-    | 'openTranscript'
-  >;
   attachmentApprovals: AttachmentApprovalRegistry;
   emitSessionsChanged: (
     reason: SessionChangedReason,
@@ -176,6 +169,58 @@ export interface RuntimeHostSessionExecutionIpcDeps {
   newId?: () => string;
 }
 
+export interface RuntimeHostSessionObservationIpcDeps {
+  observations: Pick<
+    RuntimeHostSessionObservationRegistry,
+    | 'loadTranscriptAround'
+    | 'loadTranscriptBefore'
+    | 'observe'
+    | 'openTranscript'
+  >;
+  resolveSideConversation(sessionId: string): Promise<boolean>;
+}
+
+/** Register the complete Desktop surface available to an observation-only Session. */
+export function registerRuntimeHostSessionObservationIpc(
+  deps: RuntimeHostSessionObservationIpcDeps,
+  ipcMain: ReconnectableReadIpcMain,
+): void {
+  handleReconnectableRead(
+    ipcMain,
+    'sessions:observe',
+    async (event, sessionId: unknown, observerId: unknown) => {
+      const normalizedSessionId = requiredId(sessionId, 'Session');
+      await deps.observations.observe(
+        normalizedSessionId,
+        requiredId(observerId, 'Session observer'),
+        event.sender as RuntimeHostSessionObserverTarget,
+        await deps.resolveSideConversation(normalizedSessionId),
+      );
+    },
+  );
+  ipcMain.handle(
+    'sessions:transcript:open',
+    async (event, sessionId: unknown, consumerId: unknown) =>
+      deps.observations.openTranscript(
+        requiredId(sessionId, 'Session'),
+        requiredId(consumerId, 'Transcript consumer'),
+        event.sender as RuntimeHostTranscriptTarget,
+      ),
+  );
+  ipcMain.handle('sessions:transcript:load-before', async (event, input: unknown) => {
+    await deps.observations.loadTranscriptBefore(
+      normalizeTranscriptRangeRequest(input),
+      event.sender.id,
+    );
+  });
+  ipcMain.handle('sessions:transcript:load-around', async (event, input: unknown) => {
+    await deps.observations.loadTranscriptAround(
+      normalizeTranscriptRangeRequest(input),
+      event.sender.id,
+    );
+  });
+}
+
 /**
  * Project Host-owned Session execution onto the Desktop renderer IPC contract.
  * The adapter owns client validation and presentation events, never Runtime
@@ -220,47 +265,6 @@ export function registerRuntimeHostSessionExecutionIpc(
     },
   );
 
-  handleReconnectableRead(
-    ipcMain,
-    "sessions:observe",
-    async (event, sessionId: unknown, observerId: unknown) => {
-      const normalizedSessionId = requiredId(sessionId, "Session");
-      const normalizedObserverId = requiredId(observerId, "Session observer");
-      const session = await deps.client.getSession(normalizedSessionId);
-      if (!session) {
-        throw new Error(`Runtime Host Session not found: ${normalizedSessionId}`);
-      }
-      await deps.observations.observe(
-        normalizedSessionId,
-        normalizedObserverId,
-        event.sender as RuntimeHostSessionObserverTarget,
-        isSideConversationSession(session.labels),
-      );
-    },
-  );
-  ipcMain.handle(
-    'sessions:transcript:open',
-    async (event, sessionId: unknown, consumerId: unknown) => {
-      const result = await deps.observations.openTranscript(
-        requiredId(sessionId, 'Session'),
-        requiredId(consumerId, 'Transcript consumer'),
-        event.sender as RuntimeHostTranscriptTarget,
-      );
-      return result;
-    },
-  );
-  ipcMain.handle('sessions:transcript:load-before', async (event, input: unknown) => {
-    await deps.observations.loadTranscriptBefore(
-      normalizeTranscriptRangeRequest(input),
-      event.sender.id,
-    );
-  });
-  ipcMain.handle('sessions:transcript:load-around', async (event, input: unknown) => {
-    await deps.observations.loadTranscriptAround(
-      normalizeTranscriptRangeRequest(input),
-      event.sender.id,
-    );
-  });
   handleReconnectableRead(ipcMain, 'sessions:listTurns', async (_event, sessionId: unknown) =>
     deps.client.listSessionTurns(requiredId(sessionId, 'Session')),
   );
diff --git a/apps/desktop/src/main/runtime-host-session-observation-registry.ts b/apps/desktop/src/main/runtime-host-session-observation-registry.ts
index 0e95057c5..289c154b6 100644
--- a/apps/desktop/src/main/runtime-host-session-observation-registry.ts
+++ b/apps/desktop/src/main/runtime-host-session-observation-registry.ts
@@ -209,6 +209,37 @@ export class RuntimeHostSessionObservationRegistry {
     return [...new Set([...this.#registrations.values()].map((registration) => registration.sessionId))];
   }
 
+  trackedSessionIds(): string[] {
+    return [
+      ...new Set([
+        ...[...this.#registrations.values()].map((registration) => registration.sessionId),
+        ...[...this.#transcripts.values()].map((registration) => registration.sessionId),
+      ]),
+    ];
+  }
+
+  async forgetSession(sessionId: string): Promise<void> {
+    const source = this.#source;
+    const observations = [...this.#registrations].filter(
+      ([, registration]) => registration.sessionId === sessionId,
+    );
+    const transcripts = [...this.#transcripts].filter(
+      ([, registration]) => registration.sessionId === sessionId,
+    );
+    for (const [observerId, registration] of observations) {
+      this.#deleteRegistration(observerId, registration);
+    }
+    for (const [consumerId, registration] of transcripts) {
+      this.#deleteTranscript(consumerId, registration);
+    }
+    if (source) {
+      await Promise.allSettled([
+        ...observations.map(([observerId]) => source.unobserve(observerId)),
+        ...transcripts.map(([consumerId]) => source.closeTranscript?.(consumerId)),
+      ]);
+    }
+  }
+
   detach(source: SessionObservationSource): void {
     if (this.#source === source) {
       this.#source = undefined;
diff --git a/apps/desktop/src/main/runtime-host-session-subscription-owner.ts b/apps/desktop/src/main/runtime-host-session-subscription-owner.ts
index 096683bf5..4f4b7ed1f 100644
--- a/apps/desktop/src/main/runtime-host-session-subscription-owner.ts
+++ b/apps/desktop/src/main/runtime-host-session-subscription-owner.ts
@@ -404,18 +404,22 @@ export class RuntimeHostSessionSubscriptionOwner {
 }
 
 function subscriptionClosedError(
-  reason: "access_revoked" | "slow_consumer" | "session_removed",
+  reason: "slow_consumer" | "session_removed" | 'access_revoked',
 ): Error {
-  return reason !== "slow_consumer"
-    ? new SessionRemovedSubscriptionError(
-        reason === "access_revoked"
-          ? "Runtime Host Session access was revoked"
-          : "Runtime Host Session was removed while it was observed",
-      )
-    : new RuntimeHostSubscriptionError(
-        "slow_consumer",
-        "Runtime Host Session subscription closed for a slow consumer",
-      );
+  if (reason === 'session_removed') {
+    return new SessionRemovedSubscriptionError(
+      'Runtime Host Session was removed while it was observed',
+    );
+  }
+  if (reason === 'access_revoked') {
+    return new SessionRemovedSubscriptionError(
+      'Access to the shared Runtime Host Session was revoked',
+    );
+  }
+  return new RuntimeHostSubscriptionError(
+    'slow_consumer',
+    'Runtime Host Session subscription closed for a slow consumer',
+  );
 }
 
 function isRecoverableSubscriptionFailure(error: unknown): boolean {
diff --git a/apps/desktop/src/main/runtime-host-shell-runs-ipc-main.ts b/apps/desktop/src/main/runtime-host-shell-runs-ipc-main.ts
index 7c9276373..fa7651209 100644
--- a/apps/desktop/src/main/runtime-host-shell-runs-ipc-main.ts
+++ b/apps/desktop/src/main/runtime-host-shell-runs-ipc-main.ts
@@ -20,6 +20,7 @@
 import { randomUUID } from 'node:crypto';
 import type { ShellRunUpdate } from '@maka/core/events';
 import type { ShellRunPtySnapshot } from '@maka/runtime/shell-run-contract';
+import type { SessionDomainChange } from '@maka/runtime-host/protocol';
 import { RuntimeHostOperationError } from '@maka/runtime-host/client';
 import {
   handleReconnectableRead,
@@ -39,6 +40,38 @@ export type RuntimeHostShellRunsClient = Pick<
   | 'stopRuntimeResource'
 >;
 
+export type RuntimeHostShellRunQueriesClient = Pick<
+  DesktopRuntimeHostClient,
+  'getRuntimeResource' | 'listRuntimeResources'
+>;
+
+export interface RuntimeHostShellRunQueriesIpcHandle {
+  sessionDomainChanged(change: SessionDomainChange): void;
+  sessionSubscriptionRecovered(sessionId: string): void;
+}
+
+export function registerRuntimeHostShellRunQueriesIpc(
+  deps: {
+    client: RuntimeHostShellRunQueriesClient;
+    sendToRenderer?(channel: string, payload: unknown): void;
+    onError?(error: unknown): void;
+  },
+  ipcMain: ReconnectableReadIpcMain,
+): RuntimeHostShellRunQueriesIpcHandle {
+  handleReconnectableRead(ipcMain, 'shell-runs:list', (_event, sessionId: unknown) =>
+    deps.client.listRuntimeResources(requiredId(sessionId, 'Session')),
+  );
+  return {
+    sessionDomainChanged(change) {
+      if (change.domain !== 'runtime_resource') return;
+      void refreshRuntimeResources(deps, change.sessionId, change.resources);
+    },
+    sessionSubscriptionRecovered(sessionId) {
+      deps.sendToRenderer?.('shell-runs:resync', { sessionId });
+    },
+  };
+}
+
 export function registerRuntimeHostShellRunsIpc(
   deps: {
     client: RuntimeHostShellRunsClient;
@@ -60,10 +93,6 @@ export function registerRuntimeHostShellRunsIpc(
     newId,
     deps.sessionObserver,
   );
-
-  handleReconnectableRead(ipcMain, 'shell-runs:list', (_event, sessionId: unknown) =>
-    deps.client.listRuntimeResources(requiredId(sessionId, 'Session')),
-  );
   ipcMain.handle('shell-runs:start', async (_event, sessionId: unknown) => {
     const normalizedSessionId = requiredId(sessionId, 'Session');
     const started = await deps.client.startRuntimeResource({
@@ -95,6 +124,25 @@ export function registerRuntimeHostShellRunsIpc(
   return { close: () => controllers.close() };
 }
 
+async function refreshRuntimeResources(
+  deps: {
+    client: Pick<DesktopRuntimeHostClient, 'getRuntimeResource'>;
+    sendToRenderer?(channel: string, payload: unknown): void;
+    onError?(error: unknown): void;
+  },
+  sessionId: string,
+  resources: readonly { ref: string }[],
+): Promise<void> {
+  for (const resource of resources) {
+    try {
+      const update = await deps.client.getRuntimeResource(sessionId, resource.ref);
+      if (update) deps.sendToRenderer?.('shell-runs:update', update);
+    } catch (error) {
+      deps.onError?.(error);
+    }
+  }
+}
+
 interface RuntimeResourceIdentity {
   readonly sessionId: string;
   readonly ref: string;
diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts
index 94dcc8db3..917aec24b 100644
--- a/apps/desktop/src/preload/bridge-contract.d.ts
+++ b/apps/desktop/src/preload/bridge-contract.d.ts
@@ -109,6 +109,11 @@ import type {
   OperationOutcome,
   OperationOutput,
 } from '@maka/runtime-host/protocol';
+import type {
+  CollaborationAccessQueryResult,
+  CollaborationInvitationPrepareResult,
+  CollaborationPrincipalRevokeResult,
+} from '@maka/runtime-host/protocol';
 import type { AgentGraphEpochDirectory } from '@maka/runtime-host/client';
 import type {
   RuntimeHostServiceManagementFrame,
@@ -314,6 +319,24 @@ export interface DesktopRuntimeHostProfileSnapshot {
   readonly pairingRecoveryPending?: true;
 }
 
+export type DesktopSessionCollaborationImportResult =
+  | { readonly kind: 'connected' }
+  | {
+      readonly kind: 'error';
+      readonly reason:
+        | 'invalid_code'
+        | 'insecure_confirmation_required'
+        | 'connection_failed';
+      readonly message?: string;
+    };
+
+export type DesktopSessionCollaborationPrepareResult =
+  | {
+      readonly kind: 'prepared';
+      readonly invitation: CollaborationInvitationPrepareResult;
+    }
+  | { readonly kind: 'insecure_confirmation_required' };
+
 export interface DesktopRuntimeHostRef {
   readonly profileId: string;
   readonly hostId: string;
@@ -650,6 +673,22 @@ export interface DesktopSessionUsageSummary extends UsageSummaryV2 {
 }
 
 export interface MakaBridge {
+  sessionCollaboration: {
+    prepareInvitation(
+      sessionId: string,
+      allowInsecure?: boolean,
+    ): Promise<DesktopSessionCollaborationPrepareResult>;
+    getAccess(sessionId: string): Promise<CollaborationAccessQueryResult>;
+    revokePrincipal(
+      sessionId: string,
+      principalId: string,
+    ): Promise<CollaborationPrincipalRevokeResult>;
+    importInvitation(input: {
+      readonly code: string;
+      readonly allowInsecure?: boolean;
+    }): Promise<DesktopSessionCollaborationImportResult>;
+  };
+
   runtimeHost: {
     query<K extends RendererRuntimeHostQueryOperation>(
       operation: K,
diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts
index 0ad27fe09..158a8e100 100644
--- a/apps/desktop/src/preload/preload.ts
+++ b/apps/desktop/src/preload/preload.ts
@@ -500,7 +500,13 @@ async function loadNewTaskCatalog(): Promise<DesktopNewTaskCatalog> {
     ) as DesktopRuntimeHostProfileSnapshot;
     await runtimeHostScopeList();
     const hosts = await Promise.all(
-      profiles.entries.filter((entry) => entry.enabled).map(async (entry): Promise<DesktopNewTaskHost> => {
+      profiles.entries
+        .filter(
+          (entry) =>
+            entry.enabled &&
+            (entry.profile.kind !== 'remote' || entry.profile.access !== 'session_guest'),
+        )
+        .map(async (entry): Promise<DesktopNewTaskHost> => {
         if (entry.readiness !== 'ready' || !entry.hostId) {
           return {
             profile: entry.profile,
@@ -546,7 +552,7 @@ async function loadNewTaskCatalog(): Promise<DesktopNewTaskCatalog> {
             message: error instanceof Error ? error.message : String(error),
           };
         }
-      }),
+        }),
     );
     if (generation !== activeRuntimeHostGeneration) continue;
     return { defaultProfileId: profiles.defaultProfileId, hosts };
@@ -1173,6 +1179,36 @@ const browserSelection = createBrowserSelectionCoordinator(runtimeHostSessionRef
 
 const makaBridge = {
   runtimeHost,
+  sessionCollaboration: {
+    async prepareInvitation(sessionId, allowInsecure = false) {
+      const session = await runtimeHostSessionRef(sessionId);
+      return ipcRenderer.invoke(
+        'session-collaboration:prepare',
+        session.scope,
+        session.sessionId,
+        allowInsecure,
+      );
+    },
+    async getAccess(sessionId) {
+      const session = await runtimeHostSessionRef(sessionId);
+      return ipcRenderer.invoke(
+        'session-collaboration:getAccess',
+        session.scope,
+        session.sessionId,
+      );
+    },
+    async revokePrincipal(sessionId, principalId) {
+      const session = await runtimeHostSessionRef(sessionId);
+      return ipcRenderer.invoke(
+        'session-collaboration:revokePrincipal',
+        session.scope,
+        principalId,
+      );
+    },
+    importInvitation({ code, allowInsecure = false }) {
+      return ipcRenderer.invoke('session-collaboration:import', code, allowInsecure);
+    },
+  },
   runtimeHostProfiles: {
     getSnapshot() {
       return ipcRenderer.invoke('runtime-host-profiles:getSnapshot');
diff --git a/apps/desktop/src/renderer/app-shell-effects.ts b/apps/desktop/src/renderer/app-shell-effects.ts
index fa2fd09a1..da3017850 100644
--- a/apps/desktop/src/renderer/app-shell-effects.ts
+++ b/apps/desktop/src/renderer/app-shell-effects.ts
@@ -516,6 +516,7 @@ export function useActiveSessionEvents(options: {
 
 export function useShellRunUpdates(options: {
   activeId: string | undefined;
+  hydrate?: boolean;
   setShellRunUpdatesBySession: (updater: (current: ShellRunUpdatesBySession) => ShellRunUpdatesBySession) => void;
 }) {
   const applyUpdates = useEffectEvent(
@@ -544,6 +545,7 @@ export function useShellRunUpdates(options: {
     let retryTimer: ReturnType<typeof globalThis.setTimeout> | undefined;
     let retryDelayMs = 250;
     const hydration = new ShellRunHydration();
+    if (options.hydrate === false) hydration.commit(0);
     const unsubscribe = window.maka.shellRuns.subscribeUpdates((update) => {
       if (disposed) return;
       const live = hydration.accept(update);
@@ -554,6 +556,7 @@ export function useShellRunUpdates(options: {
       }
     });
     const hydrate = (epoch: number) => {
+      if (options.hydrate === false) return;
       void window.maka.shellRuns
         .list(sessionId)
         .then((updates) => {
@@ -577,7 +580,7 @@ export function useShellRunUpdates(options: {
         });
     };
     const unsubscribeResync = window.maka.shellRuns.subscribeResync((event) => {
-      if (disposed || event.sessionId !== sessionId) return;
+      if (disposed || options.hydrate === false || event.sessionId !== sessionId) return;
       const epoch = hydration.begin();
       retryDelayMs = 250;
       if (retryTimer !== undefined) {
@@ -586,14 +589,14 @@ export function useShellRunUpdates(options: {
       }
       hydrate(epoch);
     });
-    hydrate(hydration.begin());
+    if (options.hydrate !== false) hydrate(hydration.begin());
     return () => {
       disposed = true;
       if (retryTimer !== undefined) globalThis.clearTimeout(retryTimer);
       unsubscribe();
       unsubscribeResync();
     };
-  }, [options.activeId]);
+  }, [options.activeId, options.hydrate]);
 }
 
 export function useSessionEventHealthPolling(options: {
diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx
index e390ab277..edb6e038f 100644
--- a/apps/desktop/src/renderer/app-shell.tsx
+++ b/apps/desktop/src/renderer/app-shell.tsx
@@ -107,6 +107,9 @@ import {
   type TaskEntryError,
 } from './features/task-entry';
 import { useNewTaskChoice } from './use-new-task-choice';
+import { SessionCollaborationDialog } from './session-collaboration-dialog';
+import { getSessionCollaborationCopy } from './locales/session-collaboration-copy';
+import { useSessionCollaborationDialog } from './use-session-collaboration-dialog';
 import { NEW_TASK_PENDING_KEY } from './pending-items';
 import { parseDesktopSlashCommand } from './desktop-slash-command';
 import {
@@ -345,6 +348,7 @@ function AppShellContent({
 }) {
   const toastApi = useToast();
   const [appUpdateStatus, setAppUpdateStatus] = useState<AppUpdateStatus | null>(null);
+  const sharedSessionDialog = useSessionCollaborationDialog();
   const updateInstallInFlightRef = useRef(false);
   const notifiedInstallErrorRef = useRef<string | null>(null);
   const previousInterruptionShownRef = useRef(false);
@@ -374,6 +378,10 @@ function AppShellContent({
     setMessageLoadPending,
     sessionUiController,
   } = useAppShellSessionWorkspace(toastApi);
+  const activeCatalogSession = sessions.find((session) => session.id === activeId);
+  const sharedSessionActive =
+    (activeCatalogSession as DesktopSessionSummary | undefined)?.shared === true;
+  const ownerActiveId = activeCatalogSession && !sharedSessionActive ? activeId : undefined;
   const interactionHydrationEpochRef = useRef(new Map<string, number>());
   const markInteractionChanged = useCallback((sessionId: string) => {
     const epochs = interactionHydrationEpochRef.current;
@@ -535,7 +543,8 @@ function AppShellContent({
   const { memoryActive, refreshMemoryActive } = useShellMemoryPill({
     toastApi,
     uiLocale,
-    sessionId: activeId,
+    sessionId: ownerActiveId,
+    disabled: sharedSessionActive,
   });
   const newTaskHost = taskEntry.selectors.selectedHost
     ? {
@@ -556,7 +565,7 @@ function AppShellContent({
   const sessionHostConnections = useShellConnections({
     toastApi,
     uiLocale,
-    target: { kind: 'session', sessionId: activeId },
+    target: { kind: 'session', sessionId: ownerActiveId },
   });
   const startupConnectionSnapshot =
     initialOnboardingSnapshot ?? onboarding.mountedSnapshotHandoff;
@@ -586,13 +595,13 @@ function AppShellContent({
     return Promise.all([
       defaultHostConnections.refreshConnections(),
       newTaskConnections.refreshConnections(),
-      ...(activeId ? [sessionHostConnections.refreshConnections()] : []),
+      ...(ownerActiveId ? [sessionHostConnections.refreshConnections()] : []),
     ]).then(() => undefined);
   }
   function handleConnectionEvent(event: ConnectionEvent): void {
     defaultHostConnections.handleConnectionEvent(event);
     newTaskConnections.handleConnectionEvent(event);
-    if (activeId) sessionHostConnections.handleConnectionEvent(event);
+    if (ownerActiveId) sessionHostConnections.handleConnectionEvent(event);
   }
   const onboardingState = onboarding.snapshot?.state;
   const onboardingSettled = hasSettledInitialOnboarding(onboarding.snapshot?.milestones ?? []);
@@ -800,10 +809,10 @@ function AppShellContent({
     resumePendingSessionId,
     resumeParkDescriptionBySession,
     resumeInterruptedSession,
-  } = useShellResume({ activeId, toastApi, shellCopy, uiLocale });
+  } = useShellResume({ activeId: ownerActiveId, toastApi, shellCopy, uiLocale });
   const rendererMountedRef = useRef(true);
   const goals = useGoalController({
-    activeSessionId: activeId,
+    activeSessionId: ownerActiveId,
     reportError: showSessionError,
   });
   // Set of session ids whose backend / connection is no longer usable —
@@ -818,11 +827,11 @@ function AppShellContent({
       }),
     [sessions, onboarding.snapshot?.sessionSendOutcomes],
   );
-  const activeInteraction = activeInteractionFor(interactionBySession, activeId);
+  const activeInteraction = activeInteractionFor(interactionBySession, ownerActiveId);
   const activeSandboxBoundary =
     activeInteraction?.type === 'sandbox_boundary_request' ? activeInteraction : undefined;
   const activeQuestion = activeInteraction?.type === 'user_question_request' ? activeInteraction : undefined;
-  const activeSession = sessions.find((session) => session.id === activeId);
+  const activeSession = activeCatalogSession;
   const activeMessageQueue = activeId ? messageQueueBySession[activeId] : undefined;
   const activeMessageSubmitting = transientMessages.length > 0;
   const activeDesktopSession = activeSession;
@@ -1220,32 +1229,32 @@ function AppShellContent({
     unreadable: activeExecutionBoundaryUnreadable,
     reading: activeExecutionBoundaryReading,
     reload: reloadActiveExecutionBoundary,
-  } = useActiveExecutionBoundary(activeId, activeSessionForView?.permissionMode);
+  } = useActiveExecutionBoundary(ownerActiveId, activeSessionForView?.permissionMode);
   // The session view only subscribes to the session it shows, so a request
   // raised while another session was active never reaches this surface as a
   // live event — and neither does one raised before the window existed. The
   // runtime holds every unanswered request, so read them back whenever the
   // active session changes (#2072).
   useEffect(() => {
-    if (!activeId) return;
+    if (!ownerActiveId) return;
     let cancelled = false;
-    const hydrationEpoch = interactionHydrationEpochRef.current.get(activeId) ?? 0;
+    const hydrationEpoch = interactionHydrationEpochRef.current.get(ownerActiveId) ?? 0;
     void window.maka.sessions
-      .listActiveInteractions(activeId)
+      .listActiveInteractions(ownerActiveId)
       .then((requests) => {
         if (
           cancelled ||
-          (interactionHydrationEpochRef.current.get(activeId) ?? 0) !== hydrationEpoch
+          (interactionHydrationEpochRef.current.get(ownerActiveId) ?? 0) !== hydrationEpoch
         ) {
           return;
         }
-        sessionUiController.setInteractionBySession((current) => reconcileInteractions(current, activeId, requests));
+        sessionUiController.setInteractionBySession((current) => reconcileInteractions(current, ownerActiveId, requests));
       })
       .catch(() => {});
     return () => {
       cancelled = true;
     };
-  }, [activeId, sessionUiController.setInteractionBySession]);
+  }, [ownerActiveId, sessionUiController.setInteractionBySession]);
   useEffect(
     () =>
       window.maka.sessions.subscribeActiveInteractions(({ sessionId, interactions }) => {
@@ -1262,7 +1271,7 @@ function AppShellContent({
     activeId ? (activeSessionForView?.permissionMode ?? 'ask') : newTaskPermissionMode,
   );
   const activePermissionMode = activeBoundarySurface.permissionMode;
-  const planMode = usePlanModeState(activeSessionForView);
+  const planMode = usePlanModeState(sharedSessionActive ? undefined : activeSessionForView);
   const planConversationItems = (planMode.state?.proposals ?? []).map((proposal) => ({
     id: proposal.proposalId,
     afterTurnId: proposal.turnId,
@@ -1446,10 +1455,10 @@ function AppShellContent({
   } = useAppShellProjectContext({
     uiLocale,
     rendererMountedRef,
-    sessionId: activeId,
-    sessionCwd: activeSession?.cwd,
-    sessionProjectId: activeSession?.projectId,
-    sessionProfileKind: activeDesktopSession?.profileKind,
+    sessionId: ownerActiveId,
+    sessionCwd: sharedSessionActive ? undefined : activeSession?.cwd,
+    sessionProjectId: sharedSessionActive ? undefined : activeSession?.projectId,
+    sessionProfileKind: sharedSessionActive ? undefined : activeDesktopSession?.profileKind,
     onProjectSelected: (ownerSessionId) => {
       void refreshProjectSkillsRef.current();
       if (ownerSessionId && activeIdRef.current === ownerSessionId) openNewTaskSurface();
@@ -1528,7 +1537,7 @@ function AppShellContent({
   const taskReadiness = useTaskSubmissionReadiness(
     taskReadinessRequest,
     onboarding.snapshot,
-    activeId,
+    ownerActiveId,
     activeId ? undefined : taskEntry.selectors.target,
   );
   const taskReadinessNotice = deriveTaskReadinessNotice(taskReadiness.snapshot, uiLocale);
@@ -1542,10 +1551,12 @@ function AppShellContent({
   // The titlebar names the directory the ACTIVE session runs in, so it reads
   // the same projected project state the picker does — `projectInfo` already
   // resolves to the session's own cwd once a session owns it.
-  const titlebarProjectName = deriveTitlebarProjectName({
-    projectName: currentProject?.name,
-    projectPath: projectInfo?.projectPath,
-  });
+  const titlebarProjectName = sharedSessionActive
+    ? undefined
+    : deriveTitlebarProjectName({
+        projectName: currentProject?.name,
+        projectPath: projectInfo?.projectPath,
+      });
   const { startModeSession } = useStableActions(createAppShellSessionStartActions, {
     uiLocale,
     activeIdRef,
@@ -1625,8 +1636,12 @@ function AppShellContent({
   // `ComposerMentionsProvider` below, so its reloads do not re-render the shell.
   const composerMentionsSurface: ComposerMentionsSurface = {
     skillCatalogRevision: moduleHub.selectors.skillCatalogRevision,
-    sessionId: activeId,
-    projectPath: activeId ? projectInfo?.projectPath : taskEntry.selectors.projectPath,
+    sessionId: ownerActiveId,
+    projectPath: activeId
+      ? ownerActiveId
+        ? projectInfo?.projectPath
+        : undefined
+      : taskEntry.selectors.projectPath,
     newTaskTarget: activeId ? undefined : taskEntry.selectors.target,
     newSessionModel: newChatModel,
     newSessionCollaborationMode: newChatPlanModeActive ? 'plan' : 'agent',
@@ -1635,7 +1650,7 @@ function AppShellContent({
     newSessionPermissionMode: newTaskPermissionMode,
   };
 
-  const hasModalOpen = helpOpen || paletteOpen || searchModalOpen;
+  const hasModalOpen = helpOpen || paletteOpen || searchModalOpen || sharedSessionDialog.target !== undefined;
   const shellObscured = hasModalOpen || settingsOpen;
   const contextCompactionPresentation = useMemo(
     () =>
@@ -2763,6 +2778,14 @@ function AppShellContent({
                    next one. A remount ties the edit to the session it belongs to. */
                 key={activeSessionForView.id}
                 sessionName={activeSessionForView.name}
+                readOnly={sharedSessionActive}
+                action={sharedSessionActive ? undefined : {
+                  label: getSessionCollaborationCopy(uiLocale).shareAction,
+                  onClick: () => sharedSessionDialog.open({
+                    sessionId: activeSessionForView.id,
+                    sessionName: activeSessionForView.name,
+                  }),
+                }}
                 onRenameSession={(name) => {
                   void sessionNavigationCommandsRef.current?.renameSession(activeSessionForView.id, name);
                 }}
@@ -2779,7 +2802,7 @@ function AppShellContent({
                 parentSession={titlebarParentSession}
               />
             )}
-            {!VIEWS_WITHOUT_WORKSPACE_ACTIONS.has(agentsView) && (
+            {!sharedSessionActive && !VIEWS_WITHOUT_WORKSPACE_ACTIONS.has(agentsView) && (
               <WorkbarTitlebarActions
                 available={workbarAvailable}
                 collapsed={workbar.selectors.rightCollapsed}
@@ -2874,7 +2897,7 @@ function AppShellContent({
                 hidden={navSelection.section !== 'sessions'}
                 composer={
                   <>
-                    {navSelection.section === 'sessions' &&
+                    {!sharedSessionActive && navSelection.section === 'sessions' &&
                     activeId &&
                     activeSessionForView &&
                     !isLinkedSubagentSession(activeSessionForView) ? (
@@ -2885,7 +2908,7 @@ function AppShellContent({
                         onOpenSession={openSessionInChat}
                       />
                     ) : null}
-                    {navSelection.section === 'sessions' ? <PlanExecutionPanel planMode={planMode} /> : null}
+                    {!sharedSessionActive && navSelection.section === 'sessions' ? <PlanExecutionPanel planMode={planMode} /> : null}
                     {workHubEnabled && navSelection.section === 'sessions' && activeId ? (
                       <Button
                         className="workhub-return"
@@ -2895,6 +2918,11 @@ function AppShellContent({
                         onClick={openWorkHub}
                       />
                     ) : null}
+                    {sharedSessionActive ? (
+                      <div className="sessionCollaborationReadOnly">
+                        {getSessionCollaborationCopy(uiLocale).observeHelp}
+                      </div>
+                    ) : (
                     <ChatComposerRegion
                   workspacePicker={workspacePicker}
                   composerRef={composerRef}
@@ -3050,6 +3078,7 @@ function AppShellContent({
                       : undefined
                   }
                     />
+                    )}
                   </>
                 }
               >
@@ -3077,19 +3106,19 @@ function AppShellContent({
                 renderProviderMark={(type) => <ProviderLogo type={type} compact />}
                 modelChoices={chatModelChoices}
                 modelChangePending={modelChangePending}
-                onModelChange={(input) => setSessionModel(input)}
+                onModelChange={sharedSessionActive ? undefined : (input) => setSessionModel(input)}
                 userLabel={userLabel}
                 memoryActive={memoryActive}
-                onOpenMemorySettings={() => openSettingsSection('memory')}
+                onOpenMemorySettings={sharedSessionActive ? undefined : () => openSettingsSection('memory')}
                 goalIndicator={goals.selectors.indicator}
                 messageLoadError={activeId ? messageLoadErrorBySession[activeId] : undefined}
                 messageLoadRetryPending={activeId ? messageRetryPendingBySession[activeId] === true : false}
                 onRetryMessages={activeId ? () => void retryMessages(activeId) : undefined}
                 deriveTurnPresentation={deriveTurnPresentation}
-                onTurnFooterAction={handleTurnFooterAction}
-                onSwitchToBypassAndRetry={handleSwitchToBypassAndRetry}
-                onEditUserMessage={(turnId) => { void beginEditUserMessage(turnId); }}
-                safeResumeAction={activeId ? {
+                onTurnFooterAction={sharedSessionActive ? undefined : handleTurnFooterAction}
+                onSwitchToBypassAndRetry={sharedSessionActive ? undefined : handleSwitchToBypassAndRetry}
+                onEditUserMessage={sharedSessionActive ? undefined : (turnId) => { void beginEditUserMessage(turnId); }}
+                safeResumeAction={!sharedSessionActive && activeId ? {
                   pending: resumePendingSessionId === activeId,
                   detail: resumeParkDescriptionBySession[activeId],
                   onResume: () => { void resumeInterruptedSession(); },
@@ -3120,10 +3149,14 @@ function AppShellContent({
                 onRevisionNavigate={openSessionInChat}
                 onNew={createSession}
                 onPromptSuggestion={(prompt) => composerRef.current?.appendText(prompt)}
-                onQuoteSelection={(selection) => {
-                  addQuote(selection);
-                  composerRef.current?.focus();
-                }}
+                onQuoteSelection={
+                  sharedSessionActive
+                    ? undefined
+                    : (selection) => {
+                        addQuote(selection);
+                        composerRef.current?.focus();
+                      }
+                }
                 onAskAboutSelection={
                   activeId
                     ? (input) => {
@@ -3214,6 +3247,14 @@ function AppShellContent({
       <GoalHost model={goals.host} />
       <TaskEntryHost model={taskEntry.host} />
       <RuntimeHostSshTerminalDialog />
+      {sharedSessionDialog.target ? (
+        <SessionCollaborationDialog
+          mode="share"
+          sessionId={sharedSessionDialog.target.sessionId}
+          sessionName={sharedSessionDialog.target.sessionName}
+          onClose={sharedSessionDialog.close}
+        />
+      ) : null}
 
       <AppShellOverlays
         settingsOpen={settingsOpen}
diff --git a/apps/desktop/src/renderer/locales/session-collaboration-copy.ts b/apps/desktop/src/renderer/locales/session-collaboration-copy.ts
new file mode 100644
index 000000000..0a7779bb2
--- /dev/null
+++ b/apps/desktop/src/renderer/locales/session-collaboration-copy.ts
@@ -0,0 +1,98 @@
+/*
+ * 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 { UiCatalog, UiLocale } from '@maka/core/ui-locale';
+
+const ZH = {
+    shareAction: '分享此任务',
+    shareTitle: '分享任务',
+    shareDescription: '创建一次性邀请,让另一台 Maka 访问这个任务。',
+    disclosureTitle: '分享前请确认',
+    disclosureBody:
+      '对方将看到这个任务已有和之后产生的全部可见内容,包括其中已经出现的文件路径、凭据或其他秘密。撤销访问只能阻止后续读取,无法收回对方已经复制的内容。',
+    observe: '只读',
+    observeHelp: '查看完整历史和实时更新',
+    createInvitation: '创建邀请',
+    invitationCode: '一次性邀请码',
+    invitationHelp: '邀请码包含连接地址和访客凭据,不包含所有者凭据。',
+    copy: '复制邀请码',
+    copied: '邀请码已复制',
+    close: '完成',
+    activeAccess: '当前访问',
+    noAccess: '尚未分享给任何人',
+    pending: '待领取',
+    active: '已连接',
+    revoke: '撤销',
+    joinAction: '加入共享任务',
+    joinTitle: '加入共享任务',
+    joinDescription: '粘贴邀请码,建立独立的访客连接。',
+    code: '邀请码',
+    join: '加入',
+    invalidCode: '邀请码无效',
+    connectionFailed: '无法加入共享任务',
+    insecureTitle: '此连接未加密',
+    insecureBody: '访客凭据、完整任务内容和轮次请求可能被同一网络中的第三方截获。仅在你了解并接受风险时继续。',
+    shareInsecure: '接受风险并创建',
+    joinInsecure: '接受风险并加入',
+    sharedBadge: '共享',
+};
+
+type SessionCollaborationCopy = {
+  readonly [Key in keyof typeof ZH]: string;
+};
+
+const EN = {
+    shareAction: 'Share this task',
+    shareTitle: 'Share task',
+    shareDescription: 'Create a one-time invitation for another Maka installation.',
+    disclosureTitle: 'Confirm what will be shared',
+    disclosureBody:
+      'The Guest can read all existing and future visible content in this task, including paths, credentials, or other secrets already present. Revoking access stops future reads but cannot retract copied content.',
+    observe: 'Read only',
+    observeHelp: 'Read the full history and live updates',
+    createInvitation: 'Create invitation',
+    invitationCode: 'One-time invitation code',
+    invitationHelp: 'The code contains the connection address and Guest credential, never the Owner credential.',
+    copy: 'Copy invitation',
+    copied: 'Invitation copied',
+    close: 'Done',
+    activeAccess: 'Current access',
+    noAccess: 'No one has access yet',
+    pending: 'Pending',
+    active: 'Connected',
+    revoke: 'Revoke',
+    joinAction: 'Join shared task',
+    joinTitle: 'Join shared task',
+    joinDescription: 'Paste an invitation to create an independent Guest connection.',
+    code: 'Invitation code',
+    join: 'Join',
+    invalidCode: 'The invitation code is invalid',
+    connectionFailed: 'Could not join the shared task',
+    insecureTitle: 'This connection is not encrypted',
+    insecureBody: 'The Guest credential, complete task content, and Turn requests may be intercepted by others on the network. Continue only if you understand and accept the risk.',
+    shareInsecure: 'Accept risk and create',
+    joinInsecure: 'Accept risk and join',
+    sharedBadge: 'Shared',
+} satisfies SessionCollaborationCopy;
+
+const COPY = { zh: ZH, en: EN } satisfies UiCatalog<SessionCollaborationCopy>;
+
+export function getSessionCollaborationCopy(locale: UiLocale) {
+  return COPY[locale];
+}
diff --git a/apps/desktop/src/renderer/session-collaboration-dialog.tsx b/apps/desktop/src/renderer/session-collaboration-dialog.tsx
new file mode 100644
index 000000000..f077e944b
--- /dev/null
+++ b/apps/desktop/src/renderer/session-collaboration-dialog.tsx
@@ -0,0 +1,275 @@
+/*
+ * 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 { useEffect, useState } from 'react';
+import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog';
+import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout';
+import {
+  Button,
+  FormLayout,
+  Text,
+  TextArea,
+  useToast,
+  useUiLocale,
+} from '@maka/ui';
+import type {
+  CollaborationAccessQueryResult,
+  CollaborationInvitationPrepareResult,
+} from '@maka/runtime-host/protocol';
+import { getSessionCollaborationCopy } from './locales/session-collaboration-copy.js';
+
+type Props =
+  | {
+      readonly mode: 'share';
+      readonly sessionId: string;
+      readonly sessionName: string;
+      readonly onClose: () => void;
+    }
+  | {
+      readonly mode: 'join';
+      readonly onImported: () => void;
+      readonly onClose: () => void;
+    };
+
+export function SessionCollaborationDialog(props: Props) {
+  return props.mode === 'share'
+    ? <ShareSessionDialog {...props} />
+    : <JoinSharedSessionDialog {...props} />;
+}
+
+function ShareSessionDialog(props: Extract<Props, { readonly mode: 'share' }>) {
+  const copy = getSessionCollaborationCopy(useUiLocale());
+  const toast = useToast();
+  const [access, setAccess] = useState<CollaborationAccessQueryResult>();
+  const [invitation, setInvitation] = useState<CollaborationInvitationPrepareResult>();
+  const [working, setWorking] = useState(false);
+
+  async function refresh(): Promise<void> {
+    const nextAccess = await window.maka.sessionCollaboration.getAccess(props.sessionId);
+    setAccess(nextAccess);
+    setInvitation((current) => {
+      if (!current || Date.parse(current.expiresAt) <= Date.now()) return undefined;
+      const principal = nextAccess.principals.find(
+        (candidate) => candidate.principalId === current.principalId,
+      );
+      return principal?.status === 'pending' ? current : undefined;
+    });
+  }
+
+  useEffect(() => {
+    const poll = () => void refresh().catch(() => undefined);
+    void refresh().catch((error) => toast.error(copy.shareTitle, errorMessage(error)));
+    const timer = window.setInterval(poll, 2_000);
+    return () => window.clearInterval(timer);
+  }, [props.sessionId]);
+
+  async function createInvitation(allowInsecure = false): Promise<void> {
+    setWorking(true);
+    try {
+      const result = await window.maka.sessionCollaboration.prepareInvitation(
+        props.sessionId,
+        allowInsecure,
+      );
+      if (result.kind === 'insecure_confirmation_required') {
+        const confirmed = await toast.confirm({
+          title: copy.insecureTitle,
+          description: copy.insecureBody,
+          confirmLabel: copy.shareInsecure,
+          cancelLabel: copy.close,
+        });
+        if (confirmed) await createInvitation(true);
+        return;
+      }
+      setInvitation(result.invitation);
+      await refresh();
+    } catch (error) {
+      toast.error(copy.shareTitle, errorMessage(error));
+    } finally {
+      setWorking(false);
+    }
+  }
+
+  async function copyInvitation(): Promise<void> {
+    if (!invitation) return;
+    try {
+      await navigator.clipboard.writeText(invitation.invitationCode);
+      toast.success(copy.copied);
+    } catch (error) {
+      toast.error(copy.shareTitle, errorMessage(error));
+    }
+  }
+
+  async function revokePrincipal(principalId: string): Promise<void> {
+    setWorking(true);
+    try {
+      await window.maka.sessionCollaboration.revokePrincipal(props.sessionId, principalId);
+      await refresh();
+    } catch (error) {
+      toast.error(copy.shareTitle, errorMessage(error));
+    } finally {
+      setWorking(false);
+    }
+  }
+
+  return (
+    <Dialog isOpen onOpenChange={(open) => !open && !working && props.onClose()} purpose="form" width={620}>
+      <Layout
+        header={<DialogHeader title={copy.shareTitle} subtitle={props.sessionName} onOpenChange={(open) => !open && !working && props.onClose()} />}
+        content={(
+          <LayoutContent padding={4}>
+            <div className="sessionCollaborationStack">
+              <section className="sessionCollaborationDisclosure">
+                <Text type="body" weight="semibold">{copy.disclosureTitle}</Text>
+                <Text type="supporting" color="secondary">{copy.disclosureBody}</Text>
+              </section>
+              <Text type="supporting" color="secondary">{copy.observeHelp}</Text>
+              {invitation ? (
+                <FormLayout>
+                  <TextArea
+                    label={copy.invitationCode}
+                    value={invitation.invitationCode}
+                    rows={4}
+                    hasSpellCheck={false}
+                    isReadOnly
+                    onChange={() => undefined}
+                  />
+                  <Text type="supporting" color="secondary">{copy.invitationHelp}</Text>
+                  <Button variant="secondary" label={copy.copy} onClick={() => void copyInvitation()} />
+                </FormLayout>
+              ) : (
+                <Button
+                  variant="primary"
+                  label={copy.createInvitation}
+                  isDisabled={working}
+                  onClick={() => void createInvitation()}
+                />
+              )}
+              <section className="sessionCollaborationAccess">
+                <Text type="body" weight="semibold">{copy.activeAccess}</Text>
+                {(access?.principals.length ?? 0) === 0 ? (
+                  <Text type="supporting" color="secondary">{copy.noAccess}</Text>
+                ) : access?.principals.map((principal) => (
+                    <div className="sessionCollaborationAccessRow" key={principal.principalId}>
+                      <div>
+                        <Text type="body">{principal.status === 'pending' ? copy.pending : copy.active}</Text>
+                        <Text type="supporting" color="secondary">{copy.observe}</Text>
+                      </div>
+                      <div className="sessionCollaborationAccessActions">
+                        <Button
+                          variant="secondary"
+                          size="sm"
+                          label={copy.revoke}
+                          isDisabled={working}
+                          onClick={() => void revokePrincipal(principal.principalId)}
+                        />
+                      </div>
+                    </div>
+                  ))}
+              </section>
+            </div>
+          </LayoutContent>
+        )}
+        footer={<LayoutFooter><Button variant="secondary" label={copy.close} isDisabled={working} onClick={props.onClose} /></LayoutFooter>}
+      />
+    </Dialog>
+  );
+}
+
+function JoinSharedSessionDialog(props: Extract<Props, { readonly mode: 'join' }>) {
+  const copy = getSessionCollaborationCopy(useUiLocale());
+  const toast = useToast();
+  const [code, setCode] = useState('');
+  const [working, setWorking] = useState(false);
+
+  async function join(allowInsecure = false): Promise<void> {
+    setWorking(true);
+    try {
+      const result = await window.maka.sessionCollaboration.importInvitation({
+        code: code.trim(),
+        allowInsecure,
+      });
+      if (result.kind === 'error' && result.reason === 'insecure_confirmation_required') {
+        const confirmed = await toast.confirm({
+          title: copy.insecureTitle,
+          description: copy.insecureBody,
+          confirmLabel: copy.joinInsecure,
+          cancelLabel: copy.close,
+          destructive: true,
+        });
+        if (confirmed) await join(true);
+        return;
+      }
+      if (result.kind === 'error') {
+        toast.error(copy.joinTitle, importError(copy, result.reason, result.message));
+        return;
+      }
+      props.onImported();
+      props.onClose();
+    } catch (error) {
+      toast.error(copy.joinTitle, errorMessage(error));
+    } finally {
+      setWorking(false);
+    }
+  }
+
+  return (
+    <Dialog isOpen onOpenChange={(open) => !open && !working && props.onClose()} purpose="form" width={560}>
+      <Layout
+        header={<DialogHeader title={copy.joinTitle} subtitle={copy.joinDescription} onOpenChange={(open) => !open && !working && props.onClose()} />}
+        content={(
+          <LayoutContent padding={4}>
+            <FormLayout>
+              <TextArea
+                label={copy.code}
+                value={code}
+                rows={6}
+                hasSpellCheck={false}
+                isDisabled={working}
+                onChange={setCode}
+              />
+            </FormLayout>
+          </LayoutContent>
+        )}
+        footer={(
+          <LayoutFooter>
+            <Button variant="secondary" label={copy.close} isDisabled={working} onClick={props.onClose} />
+            <Button variant="primary" label={copy.join} isDisabled={working || !code.trim()} onClick={() => void join()} />
+          </LayoutFooter>
+        )}
+      />
+    </Dialog>
+  );
+}
+
+function errorMessage(error: unknown): string {
+  return error instanceof Error ? error.message : String(error);
+}
+
+function importError(
+  copy: ReturnType<typeof getSessionCollaborationCopy>,
+  reason:
+    | 'invalid_code'
+    | 'insecure_confirmation_required'
+    | 'connection_failed',
+  message?: string,
+): string {
+  if (reason === 'invalid_code') return copy.invalidCode;
+  if (reason === 'insecure_confirmation_required') return copy.insecureBody;
+  return message ?? copy.connectionFailed;
+}
diff --git a/apps/desktop/src/renderer/settings/runtime-host-profiles-section.tsx b/apps/desktop/src/renderer/settings/runtime-host-profiles-section.tsx
index 0ec44ab77..0d7b83241 100644
--- a/apps/desktop/src/renderer/settings/runtime-host-profiles-section.tsx
+++ b/apps/desktop/src/renderer/settings/runtime-host-profiles-section.tsx
@@ -57,6 +57,8 @@ import {
 } from './runtime-host-management-dialog.js';
 import { RuntimeHostConnectionCodeDialog } from './runtime-host-connection-code-dialog.js';
 import { RuntimeHostPeerMeshDialog } from './runtime-host-peer-mesh-dialog.js';
+import { SessionCollaborationDialog } from '../session-collaboration-dialog.js';
+import { getSessionCollaborationCopy } from '../locales/session-collaboration-copy.js';
 
 type RemoteTransportKind = RuntimeHostRemoteTransport["kind"];
 
@@ -81,6 +83,7 @@ export function RuntimeHostProfilesSection(props: {
 }) {
   const locale = useUiLocale();
   const copy = getSettingsProjectsCopy(locale).runtimeHost;
+  const collaborationCopy = getSessionCollaborationCopy(locale);
   const mountedRef = useMountedRef();
   const toast = useToast();
   const [snapshot, setSnapshot] = useState<
@@ -88,6 +91,7 @@ export function RuntimeHostProfilesSection(props: {
   >();
   const [showAdd, setShowAdd] = useState(false);
   const [showOnboarding, setShowOnboarding] = useState(false);
+  const [showJoinSharedSession, setShowJoinSharedSession] = useState(false);
   const [managedTarget, setManagedTarget] = useState<RuntimeHostManagementTarget>();
   const [localAccess, setLocalAccess] = useState<DesktopLocalRuntimeHostRemoteAccessSnapshot>();
   const [connectionCodeDialog, setConnectionCodeDialog] = useState<
@@ -360,7 +364,11 @@ export function RuntimeHostProfilesSection(props: {
     ? { id: localProfile.id, name: localProfile.name, directPeerManagement: false }
     : undefined;
   const profileOptions = (snapshot?.entries ?? [])
-    .filter((entry) => entry.enabled)
+    .filter(
+      (entry) =>
+        entry.enabled &&
+        (entry.profile.kind !== 'remote' || entry.profile.access !== 'session_guest'),
+    )
     .map((entry) => ({
       value: entry.profile.id,
       label: entry.profile.name,
@@ -505,6 +513,11 @@ export function RuntimeHostProfilesSection(props: {
                   isDisabled: switching,
                   onClick: () => setConnectionCodeDialog({ mode: 'import' }),
                 },
+                {
+                  label: collaborationCopy.joinAction,
+                  isDisabled: switching,
+                  onClick: () => setShowJoinSharedSession(true),
+                },
                 {
                   label: showAdd ? copy.cancel : copy.configureManually,
                   isDisabled: switching,
@@ -622,7 +635,10 @@ export function RuntimeHostProfilesSection(props: {
             {connectedEntries.map((entry) => {
               const profile = entry.profile;
               if (profile.kind === 'local') return null;
+              const isSharedAccess =
+                profile.kind === 'remote' && profile.access === 'session_guest';
               const managedSshDestination =
+                !isSharedAccess &&
                 profile.kind === 'remote' &&
                 profile.transport.kind === 'ssh' &&
                 entry.managedService
@@ -650,6 +666,9 @@ export function RuntimeHostProfilesSection(props: {
                       {profile.kind === 'remote' && profile.transport.kind === 'libp2p-direct' ? (
                         <Badge variant="warning" label={copy.experimentalBadge} />
                       ) : null}
+                      {isSharedAccess ? (
+                        <Badge variant="neutral" label={collaborationCopy.sharedBadge} />
+                      ) : null}
                       {entry.readiness === "unavailable" ? (
                         <Badge variant="neutral" label={copy.unavailable} />
                       ) : null}
@@ -747,6 +766,15 @@ export function RuntimeHostProfilesSection(props: {
           }}
         />
       ) : null}
+      {showJoinSharedSession ? (
+        <SessionCollaborationDialog
+          mode="join"
+          onClose={() => setShowJoinSharedSession(false)}
+          onImported={() => {
+            void reload();
+          }}
+        />
+      ) : null}
     </>
   );
 }
diff --git a/apps/desktop/src/renderer/styles/settings/runtime-host.css b/apps/desktop/src/renderer/styles/settings/runtime-host.css
index ef2647120..a08d3847f 100644
--- a/apps/desktop/src/renderer/styles/settings/runtime-host.css
+++ b/apps/desktop/src/renderer/styles/settings/runtime-host.css
@@ -586,3 +586,40 @@
     height: 220px;
   }
 }
+.sessionCollaborationStack {
+  display: flex;
+  flex-direction: column;
+  gap: var(--space-4);
+}
+
+.sessionCollaborationDisclosure {
+  display: flex;
+  flex-direction: column;
+  gap: var(--space-1);
+  padding: var(--space-3);
+  border: 1px solid var(--border-subtle);
+  border-radius: var(--radius-lg);
+  background: var(--background-subtle);
+}
+
+.sessionCollaborationAccess {
+  display: flex;
+  flex-direction: column;
+  gap: var(--space-2);
+  padding-top: var(--space-2);
+  border-top: 1px solid var(--border-subtle);
+}
+
+.sessionCollaborationAccessRow {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: var(--space-3);
+  padding: var(--space-2) 0;
+}
+
+.sessionCollaborationAccessActions {
+  display: flex;
+  align-items: center;
+  gap: var(--space-1);
+}
diff --git a/apps/desktop/src/renderer/styles/shell-layout.css b/apps/desktop/src/renderer/styles/shell-layout.css
index d3f1dc168..22938c1a3 100644
--- a/apps/desktop/src/renderer/styles/shell-layout.css
+++ b/apps/desktop/src/renderer/styles/shell-layout.css
@@ -391,6 +391,12 @@ dialog:modal {
   display: flex;
   padding-left: var(--space-3);
   -webkit-app-region: no-drag;
+  align-items: center;
+  gap: var(--space-1);
+}
+
+.maka-titlebar-identity__action {
+  flex: 0 0 auto;
 }
 
 /* Every box between the strip's column and the text has to agree to shrink, or
diff --git a/apps/desktop/src/renderer/use-session-collaboration-dialog.ts b/apps/desktop/src/renderer/use-session-collaboration-dialog.ts
new file mode 100644
index 000000000..7bf81d764
--- /dev/null
+++ b/apps/desktop/src/renderer/use-session-collaboration-dialog.ts
@@ -0,0 +1,35 @@
+/*
+ * 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 { useState } from 'react';
+
+export interface SessionCollaborationDialogTarget {
+  readonly sessionId: string;
+  readonly sessionName: string;
+}
+
+export function useSessionCollaborationDialog() {
+  const [target, setTarget] = useState<SessionCollaborationDialogTarget>();
+
+  return {
+    target,
+    open: setTarget,
+    close: () => setTarget(undefined),
+  };
+}
diff --git a/apps/desktop/src/renderer/use-shell-memory-pill.ts b/apps/desktop/src/renderer/use-shell-memory-pill.ts
index 668685690..064a972e6 100644
--- a/apps/desktop/src/renderer/use-shell-memory-pill.ts
+++ b/apps/desktop/src/renderer/use-shell-memory-pill.ts
@@ -47,10 +47,12 @@ export function useShellMemoryPill({
   toastApi,
   uiLocale,
   sessionId,
+  disabled = false,
 }: {
   toastApi: ToastApi;
   uiLocale: UiLocale;
   sessionId?: string;
+  disabled?: boolean;
 }): {
   memoryActive: boolean;
   refreshMemoryActive: (failureContext?: 'load') => Promise<void>;
@@ -60,6 +62,10 @@ export function useShellMemoryPill({
   const copy = getShellCopy(uiLocale).app;
   async function refreshMemoryActive(failureContext?: 'load') {
     const sequence = ++refreshSequence.current;
+    if (disabled) {
+      setMemoryActive(false);
+      return;
+    }
     try {
       const next = sessionId
         ? await window.maka.memory.getState(sessionId)
@@ -86,7 +92,7 @@ export function useShellMemoryPill({
     return () => {
       refreshSequence.current += 1;
     };
-  }, [sessionId]);
+  }, [disabled, sessionId]);
   return {
     memoryActive,
     refreshMemoryActive,
diff --git a/apps/desktop/src/shared/desktop-session-projection.ts b/apps/desktop/src/shared/desktop-session-projection.ts
index 6893fc5d0..44a66567e 100644
--- a/apps/desktop/src/shared/desktop-session-projection.ts
+++ b/apps/desktop/src/shared/desktop-session-projection.ts
@@ -41,6 +41,8 @@ export interface DesktopSessionSummary extends SessionSummary {
   readonly profileId: string;
   readonly profileName: string;
   readonly profileKind: RuntimeHostProfileKind;
+  /** Present only for Session projections granted to a Guest principal. */
+  readonly shared?: true;
 }
 
 export interface DesktopSessionHost extends DesktopHostRef {
diff --git a/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md
index ced83f6d2..3bcd45c6b 100644
--- a/docs/astryx-surface-file-inventory.md
+++ b/docs/astryx-surface-file-inventory.md
@@ -5,7 +5,7 @@ Each row is one on-disk product surface file. Regenerated inventory must stay in
 
 Wiki bar: Design Conventions · API Use-the-System · Theming · Container Padding.
 
-**Totals:** 223 files — blocker 0, polish 1, aligned 222.
+**Totals:** 224 files — blocker 0, polish 1, aligned 223.
 
 ## Exclusions (explicit)
 
@@ -71,6 +71,7 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi
 | `apps/desktop/src/renderer/plan-mode-panel.tsx` | shell-chrome-or-panel | Badge, Banner, Button, Collapsible | aligned — uses Astryx (Badge, Banner, Button, Collapsible) | aligned |
 | `apps/desktop/src/renderer/reference-shell.css` | styles | n/a (css) | aligned — no off-rhythm control heights flagged | aligned |
 | `apps/desktop/src/renderer/remote-project-directory-dialog.tsx` | dialog-overlay | Button, Dialog, DialogHeader, HStack, Layout, LayoutContent, Text | aligned — uses Astryx (Button, Dialog, DialogHeader, HStack, Layout, LayoutContent, Text) | aligned |
+| `apps/desktop/src/renderer/session-collaboration-dialog.tsx` | dialog-overlay | Button, Dialog, DialogHeader, Layout, LayoutContent | aligned — uses Astryx (Button, Dialog, DialogHeader, Layout, LayoutContent) | aligned |
 | `apps/desktop/src/renderer/settings/about-settings-page.tsx` | settings-page | Badge, Banner, Button, List, ListItem | aligned — uses Astryx (Badge, Banner, Button, List, ListItem) | aligned |
 | `apps/desktop/src/renderer/settings/appearance-settings-page.tsx` | settings-page | Button, HStack, Text, VStack | aligned — uses Astryx (Button, HStack, Text, VStack) | aligned |
 | `apps/desktop/src/renderer/settings/bot-chat-detail.tsx` | settings-module | Banner, Button, Card, MetadataList, MetadataListItem, SegmentedControl, SegmentedControlItem, Selector, Text, VStack | aligned — uses Astryx (Banner, Button, Card, MetadataList, MetadataListItem, SegmentedControl, SegmentedControlItem, Selector) | aligned |
@@ -239,7 +240,7 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi
 | `packages/ui/src/skills-panel.tsx` | module-hub | Button, EmptyState, IconButton, List, ListItem, SegmentedControl, SegmentedControlItem, Selector, Text, TextInput, Toolbar | aligned — uses Astryx (Button, EmptyState, IconButton, List, ListItem, SegmentedControl, SegmentedControlItem, Selector) | aligned |
 | `packages/ui/src/styles.css` | ui-composition | n/a (css) | aligned — no off-rhythm control heights flagged | aligned |
 | `packages/ui/src/task-ledger-panel.tsx` | shell-chrome-or-panel | Banner, Collapsible, EmptyState, IconButton, Spinner | aligned — uses Astryx (Banner, Collapsible, EmptyState, IconButton, Spinner) | aligned |
-| `packages/ui/src/titlebar-session-identity.tsx` | shell-chrome-or-panel | BreadcrumbItem, Breadcrumbs | aligned — uses Astryx (BreadcrumbItem, Breadcrumbs) | aligned |
+| `packages/ui/src/titlebar-session-identity.tsx` | shell-chrome-or-panel | BreadcrumbItem, Breadcrumbs, IconButton, Tooltip | aligned — uses Astryx (BreadcrumbItem, Breadcrumbs, IconButton, Tooltip) | aligned |
 | `packages/ui/src/toast.tsx` | ui-composition | Button, HStack, Text, VStack | aligned — uses Astryx (Button, HStack, Text, VStack) | aligned |
 | `packages/ui/src/tool-activity.tsx` | ui-composition | Banner, Button, ChatToolCalls, List, ListItem, Text | aligned — uses Astryx (Banner, Button, ChatToolCalls, List, ListItem, Text) | aligned |
 | `packages/ui/src/tool-activity/diff-code-preview.tsx` | ui-composition | none | aligned — no raw controls; no Astryx JSX usage | aligned |
diff --git a/docs/astryx-surface-file-inventory.paths b/docs/astryx-surface-file-inventory.paths
index 89c798d6b..f9bccfbf8 100644
--- a/docs/astryx-surface-file-inventory.paths
+++ b/docs/astryx-surface-file-inventory.paths
@@ -43,6 +43,7 @@ apps/desktop/src/renderer/onboarding-hero.tsx
 apps/desktop/src/renderer/plan-mode-panel.tsx
 apps/desktop/src/renderer/reference-shell.css
 apps/desktop/src/renderer/remote-project-directory-dialog.tsx
+apps/desktop/src/renderer/session-collaboration-dialog.tsx
 apps/desktop/src/renderer/settings/about-settings-page.tsx
 apps/desktop/src/renderer/settings/appearance-settings-page.tsx
 apps/desktop/src/renderer/settings/bot-chat-detail.tsx
diff --git a/packages/cli/src/__tests__/runtime-host-cli-context.test.ts b/packages/cli/src/__tests__/runtime-host-cli-context.test.ts
index ce00c11a8..9c64a96b3 100644
--- a/packages/cli/src/__tests__/runtime-host-cli-context.test.ts
+++ b/packages/cli/src/__tests__/runtime-host-cli-context.test.ts
@@ -263,7 +263,7 @@ test('remote CLI profiles pin root identity and resolve credential outside the p
       },
       profileCatalog: {
         read: async () => ({
-          schemaVersion: 2,
+          schemaVersion: 3,
           profiles: [
             {
               id: 'office',
@@ -545,7 +545,7 @@ function incompatibleRemoteHandshake(overrides: Partial<HostIncompatible> = {}):
 
 function singleRemoteProfileCatalog(profile: RemoteRuntimeHostProfile): RuntimeHostProfileCatalog {
   return {
-    read: async () => ({ schemaVersion: 2, profiles: [profile] }),
+    read: async () => ({ schemaVersion: 3, profiles: [profile] }),
     resolve: async (profileId) => {
       assert.equal(profileId, profile.id);
       return { profile, credential: 'opaque-token' };
diff --git a/packages/cli/src/__tests__/runtime-host-profile-command.test.ts b/packages/cli/src/__tests__/runtime-host-profile-command.test.ts
index dcb92bf9a..4f89d2ce2 100644
--- a/packages/cli/src/__tests__/runtime-host-profile-command.test.ts
+++ b/packages/cli/src/__tests__/runtime-host-profile-command.test.ts
@@ -215,7 +215,7 @@ function createProfileCatalogCapture(): {
   saved: Array<{ profile: RemoteRuntimeHostProfile; credential?: string }>;
 } {
   const state: { document: RuntimeHostProfileDocument } = {
-    document: { schemaVersion: 2, profiles: [] },
+    document: { schemaVersion: 3, profiles: [] },
   };
   const saved: Array<{ profile: RemoteRuntimeHostProfile; credential?: string }> = [];
   const catalog: RuntimeHostProfileCatalog = {
@@ -225,7 +225,7 @@ function createProfileCatalogCapture(): {
     save: async (profile: RemoteRuntimeHostProfile, credential?: string) => {
       saved.push({ profile, credential });
       state.document = {
-        schemaVersion: 2,
+        schemaVersion: 3,
         profiles: [
           ...state.document.profiles.filter((candidate) => candidate.id !== profile.id),
           profile,
diff --git a/packages/runtime-host/src/__tests__/authenticated-websocket.test.ts b/packages/runtime-host/src/__tests__/authenticated-websocket.test.ts
index fb75ef9e9..99986ea8b 100644
--- a/packages/runtime-host/src/__tests__/authenticated-websocket.test.ts
+++ b/packages/runtime-host/src/__tests__/authenticated-websocket.test.ts
@@ -262,9 +262,13 @@ test('one Local IPC owner and one authenticated WebSocket Client control the sam
     const observationGrant = preparedGuest.grants.find(
       (grant) => grant.kind === 'session_observation',
     )!;
+    const guestCatalogChanged = new Promise<string>((resolve) => {
+      guest?.subscribeSessionCatalogChanges((frame) => resolve(frame.sessionId));
+    });
     await local.request('collaboration.grant.revoke', {
       grantId: observationGrant.grantId,
     });
+    assert.equal(await guestCatalogChanged, 'shared-session');
     const closed = await guestSubscription[Symbol.asyncIterator]().next();
     assert.equal(closed.done, false);
     assert.equal(closed.value?.kind, 'subscription.closed');
diff --git a/packages/runtime-host/src/__tests__/host-change-feed.test.ts b/packages/runtime-host/src/__tests__/host-change-feed.test.ts
index a296aae37..f6c5161c9 100644
--- a/packages/runtime-host/src/__tests__/host-change-feed.test.ts
+++ b/packages/runtime-host/src/__tests__/host-change-feed.test.ts
@@ -25,6 +25,8 @@ test('routes each change kind only to subscribed connections', () => {
   const feed = new HostChangeFeed();
   const configuration: unknown[] = [];
   const project: unknown[] = [];
+  const scopedSession: unknown[] = [];
+  const otherGuest: unknown[] = [];
   const all: unknown[] = [];
   feed.attachConnection(
     'configuration',
@@ -41,10 +43,23 @@ test('routes each change kind only to subscribed connections', () => {
     { configuration: true, projectCatalog: true, sessionCatalog: true, scheduledTask: true },
     { send: async (frame) => void all.push(frame) },
   );
+  feed.attachConnection(
+    'scoped-session',
+    { sessionCatalog: { sessionId: 'session-1', principalId: 'guest-1' } },
+    { send: async (frame) => void scopedSession.push(frame) },
+  );
+  feed.attachConnection(
+    'other-guest',
+    { sessionCatalog: { sessionId: 'session-1', principalId: 'guest-2' } },
+    { send: async (frame) => void otherGuest.push(frame) },
+  );
 
   feed.publishConfiguration();
   feed.publishProjectCatalog();
   feed.publishSessionCatalog('session-1');
+  feed.publishSessionCatalog('session-2');
+  feed.publishSessionCatalogAndCloseScope('session-1', 'guest-1');
+  feed.publishSessionCatalog('session-1');
   feed.publishScheduledTask(7, 'updated', 'task-1');
 
   assert.deepEqual(
@@ -55,7 +70,12 @@ test('routes each change kind only to subscribed connections', () => {
     project.map((frame) => (frame as { kind: string }).kind),
     ['project.catalog.changed'],
   );
-  assert.equal(all.length, 4);
+  assert.equal(all.length, 7);
+  assert.deepEqual(scopedSession, [
+    { kind: 'session.catalog.changed', revision: 1, sessionId: 'session-1' },
+    { kind: 'session.catalog.changed', revision: 3, sessionId: 'session-1' },
+  ]);
+  assert.equal(otherGuest.length, 3);
 });
 
 test('keeps catalog revisions independent and removes failed subscriptions', async () => {
diff --git a/packages/runtime-host/src/__tests__/host-profile.test.ts b/packages/runtime-host/src/__tests__/host-profile.test.ts
index 34d7b48c0..05f8a876e 100644
--- a/packages/runtime-host/src/__tests__/host-profile.test.ts
+++ b/packages/runtime-host/src/__tests__/host-profile.test.ts
@@ -57,7 +57,7 @@ describe('Runtime Host profiles', () => {
   test('persists WSL environments without projecting a remote credential', async () => {
     const path = await profilePath();
     const catalog = createFileRuntimeHostProfileCatalog(path, memoryCredentials());
-    assert.deepEqual(await catalog.read(), { schemaVersion: 2, profiles: [] });
+    assert.deepEqual(await catalog.read(), { schemaVersion: 3, profiles: [] });
     await catalog.create({
       id: 'ubuntu',
       name: 'Ubuntu',
@@ -117,7 +117,7 @@ describe('Runtime Host profiles', () => {
     );
 
     assert.deepEqual(await catalog.read(), {
-      schemaVersion: 2,
+      schemaVersion: 3,
       profiles: [
         {
           id: 'office',
@@ -140,7 +140,7 @@ describe('Runtime Host profiles', () => {
     if (process.platform !== 'win32') assert.equal((await stat(path)).mode & 0o777, 0o600);
 
     assert.deepEqual(await catalog.remove('office'), {
-      schemaVersion: 2,
+      schemaVersion: 3,
       profiles: [
         {
           id: 'backup',
@@ -178,7 +178,7 @@ describe('Runtime Host profiles', () => {
 
     const catalog = createFileRuntimeHostProfileCatalog(path, memoryCredentials());
     const document = await catalog.read();
-    assert.equal(document.schemaVersion, 2);
+    assert.equal(document.schemaVersion, 3);
     assert.equal(
       (JSON.parse(await readFile(path, 'utf8')) as { schemaVersion: number }).schemaVersion,
       1,
@@ -258,7 +258,7 @@ describe('Runtime Host profiles', () => {
     assert.deepEqual(await desktop.removeIfCurrent(created), {
       removed: false,
       document: {
-        schemaVersion: 2,
+        schemaVersion: 3,
         profiles: [
           {
             ...profile,
@@ -271,7 +271,7 @@ describe('Runtime Host profiles', () => {
     const rotated = await desktop.resolve(profile.id);
     assert.equal(rotated.credential, 'rotated-token');
     assert.equal((await desktop.removeIfCurrent(rotated)).removed, true);
-    assert.deepEqual(await desktop.read(), { schemaVersion: 2, profiles: [] });
+    assert.deepEqual(await desktop.read(), { schemaVersion: 3, profiles: [] });
   });
 
   test('conditionally updates one Host connection and credential', async () => {
diff --git a/packages/runtime-host/src/client/host-profile.ts b/packages/runtime-host/src/client/host-profile.ts
index b36464589..fcbb85dfd 100644
--- a/packages/runtime-host/src/client/host-profile.ts
+++ b/packages/runtime-host/src/client/host-profile.ts
@@ -60,7 +60,7 @@ import {
   type RuntimeHostWslProcessFactory,
 } from './wsl-environment.js';
 
-const PROFILE_SCHEMA_VERSION = 2;
+const PROFILE_SCHEMA_VERSION = 3;
 const PROFILE_DOCUMENT_MAX_BYTES = 64 * 1024;
 const PROFILE_COUNT_MAX = 32;
 const PROFILE_NAME_MAX_BYTES = 128;
@@ -100,6 +100,14 @@ export interface RemoteRuntimeHostProfile extends RuntimeHostProfileOfKind<'remo
   readonly name: string;
   readonly transport: RuntimeHostRemoteTransport;
   readonly rootId: string;
+  /** Present only when this profile carries a restricted Session Guest credential. */
+  readonly access?: 'session_guest';
+}
+
+export type RuntimeHostProfileAccess = 'owner' | 'session_guest';
+
+export function runtimeHostProfileAccess(profile: RuntimeHostProfile): RuntimeHostProfileAccess {
+  return profile.kind === 'remote' ? (profile.access ?? 'owner') : 'owner';
 }
 
 export type RuntimeHostRemoteTransport =
@@ -168,7 +176,10 @@ export function sameRemoteRuntimeHostProfileTarget(
   left: RemoteRuntimeHostProfile,
   right: RemoteRuntimeHostProfile,
 ): boolean {
-  return profileCredentialBinding(left) === profileCredentialBinding(right);
+  return (
+    runtimeHostProfileAccess(left) === runtimeHostProfileAccess(right) &&
+    profileCredentialBinding(left) === profileCredentialBinding(right)
+  );
 }
 
 export interface RuntimeHostProfileCatalog {
@@ -544,7 +555,11 @@ export function decodeRuntimeHostProfileDocument(value: unknown): RuntimeHostPro
     'schemaVersion',
     'profiles',
   ]);
-  if (record.schemaVersion !== 1 && record.schemaVersion !== PROFILE_SCHEMA_VERSION) {
+  if (
+    record.schemaVersion !== 1 &&
+    record.schemaVersion !== 2 &&
+    record.schemaVersion !== PROFILE_SCHEMA_VERSION
+  ) {
     throw new Error('Runtime Host profile document has an unsupported schema');
   }
   if (!Array.isArray(record.profiles) || record.profiles.length > PROFILE_COUNT_MAX) {
@@ -561,6 +576,12 @@ export function decodeRuntimeHostProfileDocument(value: unknown): RuntimeHostPro
   ) {
     throw new Error('Runtime Host profile schema 1 cannot contain activation');
   }
+  if (
+    record.schemaVersion !== PROFILE_SCHEMA_VERSION &&
+    profiles.some((profile) => profile.kind === 'remote' && profile.access === 'session_guest')
+  ) {
+    throw new Error('Runtime Host profile schema 3 is required for restricted access');
+  }
   const ids = new Set<string>();
   for (const profile of profiles) {
     if (ids.has(profile.id)) throw new Error(`Duplicate Runtime Host profile: ${profile.id}`);
@@ -652,7 +673,8 @@ class FileRuntimeHostProfileCatalog implements RuntimeHostProfileCatalog {
         throw new Error('A new Runtime Host profile must use a new profile id');
       }
       const targetChanged = previousProfile
-        ? profileTargetBinding(previousProfile) !== profileTargetBinding(profile)
+        ? profileTargetBinding(previousProfile) !== profileTargetBinding(profile) ||
+          runtimeHostProfileAccess(previousProfile) !== runtimeHostProfileAccess(profile)
         : false;
       if (targetChanged) {
         throw new Error('A Runtime Host profile target cannot be changed; create a new profile id');
@@ -753,7 +775,8 @@ class FileRuntimeHostProfileCatalog implements RuntimeHostProfileCatalog {
     const profile = decodeRemoteRuntimeHostProfile(value);
     if (
       profile.id !== expectedProfile.id ||
-      !sameRemoteRuntimeHostProfileTarget(profile, expectedProfile)
+      !sameRemoteRuntimeHostProfileTarget(profile, expectedProfile) ||
+      runtimeHostProfileAccess(profile) !== runtimeHostProfileAccess(expectedProfile)
     ) {
       return Promise.reject(new Error('A Runtime Host profile rebind must retain its connection'));
     }
@@ -872,20 +895,25 @@ export function decodeEnvironmentRuntimeHostProfile(value: unknown): Environment
 }
 
 export function decodeRemoteRuntimeHostProfile(value: unknown): RemoteRuntimeHostProfile {
-  const record = requireExactRecord(value, 'Remote Runtime Host profile', [
-    'id',
-    'name',
-    'kind',
-    'transport',
-    'rootId',
-  ]);
+  const candidate = requireRecord(value, 'Remote Runtime Host profile');
+  const record = requireExactRecord(
+    value,
+    'Remote Runtime Host profile',
+    candidate.access === undefined
+      ? ['id', 'name', 'kind', 'transport', 'rootId']
+      : ['id', 'name', 'kind', 'transport', 'rootId', 'access'],
+  );
   if (record.kind !== 'remote') throw new Error('Runtime Host profile kind must be remote');
+  if (record.access !== undefined && record.access !== 'session_guest') {
+    throw new Error('Runtime Host profile access is invalid');
+  }
   return Object.freeze({
     id: requireProfileId(record.id),
     name: requireProfileName(record.name),
     kind: 'remote',
     transport: decodeRuntimeHostRemoteTransport(record.transport),
     rootId: requireHostRootId(record.rootId),
+    ...(record.access === undefined ? {} : { access: record.access }),
   });
 }
 
@@ -1082,6 +1110,7 @@ function sameRemoteRuntimeHostProfile(
   return (
     left.id === right.id &&
     left.name === right.name &&
+    runtimeHostProfileAccess(left) === runtimeHostProfileAccess(right) &&
     profileCredentialBinding(left) === profileCredentialBinding(right)
   );
 }
@@ -1093,6 +1122,7 @@ function samePersistedRuntimeHostProfile(
   return (
     left.id === right.id &&
     left.name === right.name &&
+    runtimeHostProfileAccess(left) === runtimeHostProfileAccess(right) &&
     profileTargetBinding(left) === profileTargetBinding(right)
   );
 }
@@ -1191,12 +1221,16 @@ async function writeProfileDocument(
   document: RuntimeHostProfileDocument,
 ): Promise<void> {
   const schemaVersion = document.profiles.some(
-    (profile) =>
-      profile.kind === 'environment' ||
-      (profile.transport.kind === 'ssh' && profile.transport.activation !== undefined),
+    (profile) => profile.kind === 'remote' && profile.access === 'session_guest',
   )
     ? PROFILE_SCHEMA_VERSION
-    : 1;
+    : document.profiles.some(
+          (profile) =>
+            profile.kind === 'environment' ||
+            (profile.transport.kind === 'ssh' && profile.transport.activation !== undefined),
+        )
+      ? 2
+      : 1;
   const encoded = `${JSON.stringify({ ...document, schemaVersion }, null, 2)}\n`;
   if (Buffer.byteLength(encoded, 'utf8') > PROFILE_DOCUMENT_MAX_BYTES) {
     throw new Error('Runtime Host profile document exceeds its size limit');
diff --git a/packages/runtime-host/src/client/index.ts b/packages/runtime-host/src/client/index.ts
index 966cc2e78..3ebc57e15 100644
--- a/packages/runtime-host/src/client/index.ts
+++ b/packages/runtime-host/src/client/index.ts
@@ -51,6 +51,7 @@ export {
   decodePersistedRuntimeHostProfile,
   decodeRemoteRuntimeHostProfile,
   remoteRuntimeHostUnavailableError,
+  runtimeHostProfileAccess,
   sameRemoteRuntimeHostProfileTarget,
   sameResolvedRuntimeHostProfileTarget,
   type EnvironmentRuntimeHostProfile,
@@ -59,6 +60,7 @@ export {
   type RuntimeHostRemoteTransport,
   type ResolvedRuntimeHostProfile,
   type RuntimeHostProfile,
+  type RuntimeHostProfileAccess,
   type RuntimeHostProfileCatalog,
   type RuntimeHostProfileDocument,
 } from './host-profile.js';
diff --git a/packages/runtime-host/src/server/connection-session.ts b/packages/runtime-host/src/server/connection-session.ts
index 28b431f3d..7e800e175 100644
--- a/packages/runtime-host/src/server/connection-session.ts
+++ b/packages/runtime-host/src/server/connection-session.ts
@@ -43,7 +43,11 @@ import type {
   ClientCapabilityConnection,
   ClientCapabilityService,
 } from './client-capability-service.js';
-import type { HostChangeFeed, HostChangeSubscription } from './host-change-feed.js';
+import type {
+  HostChangeFeed,
+  HostChangeSubscription,
+  HostChangeSubscriptionMask,
+} from './host-change-feed.js';
 import type { RuntimeHostConnectionAuthority } from './connection-authority.js';
 import {
   authorizeClientCapabilityFrame,
@@ -69,6 +73,7 @@ export interface RuntimeHostConnectionSessionOptions {
   resolveContinuity(): SessionContinuityService | undefined;
   resolveClientCapabilities?(): ClientCapabilityService | undefined;
   resolveHostChanges?(): HostChangeFeed | undefined;
+  resolveSharedSessionId?(): string | undefined;
   beginOperation(frame: RequestFrame): Promise<ConnectionOperationLease | HostOperationErrorCode>;
   onTeardown(): void;
 }
@@ -320,6 +325,20 @@ export class RuntimeHostConnectionSession {
     if (this.#closed || this.#inputClosed) return;
     const service = this.#options.resolveHostChanges?.();
     if (!service || this.#hostChanges) return;
+    const sharedSessionId =
+      this.#options.connection.authority.principalKind === 'session_guest' &&
+      hasRuntimeHostOperationGrant(this.#options.connection.authority, 'session.shared.query')
+        ? this.#options.resolveSharedSessionId?.()
+        : undefined;
+    const sessionCatalog: HostChangeSubscriptionMask['sessionCatalog'] =
+      sharedSessionId !== undefined
+        ? {
+            sessionId: sharedSessionId,
+            principalId: this.#options.connection.authority.principalId,
+          }
+        : hasRuntimeHostOperationGrant(this.#options.connection.authority, 'session.catalog.query')
+          ? true
+          : undefined;
     this.#hostChanges = service.attachConnection(
       this.#options.connection.connectionId,
       {
@@ -331,11 +350,7 @@ export class RuntimeHostConnectionSession {
           this.#options.connection.authority,
           'project.catalog.query',
         ),
-        sessionCatalog:
-          hasRuntimeHostOperationGrant(
-            this.#options.connection.authority,
-            'session.catalog.query',
-          ) && this.#options.connection.authority.principalKind !== 'session_guest',
+        sessionCatalog,
         scheduledTask: hasRuntimeHostOperationGrant(
           this.#options.connection.authority,
           'scheduled-task.query',
diff --git a/packages/runtime-host/src/server/host-change-feed.ts b/packages/runtime-host/src/server/host-change-feed.ts
index 6ad41e1f1..22a24112b 100644
--- a/packages/runtime-host/src/server/host-change-feed.ts
+++ b/packages/runtime-host/src/server/host-change-feed.ts
@@ -31,12 +31,6 @@ export type HostChangeFrame =
   | SessionCatalogChangedFrame
   | ScheduledTaskChangedFrame;
 
-export type HostChangeKind =
-  | 'configuration'
-  | 'project_catalog'
-  | 'session_catalog'
-  | 'scheduled_task';
-
 export interface HostChangeSubscription {
   close(): void;
 }
@@ -44,7 +38,7 @@ export interface HostChangeSubscription {
 export interface HostChangeSubscriptionMask {
   readonly configuration?: boolean;
   readonly projectCatalog?: boolean;
-  readonly sessionCatalog?: boolean;
+  readonly sessionCatalog?: true | { readonly sessionId: string; readonly principalId: string };
   readonly scheduledTask?: boolean;
 }
 
@@ -81,7 +75,7 @@ export class HostChangeFeed {
 
   publishConfiguration(): void {
     this.#configurationRevision += 1;
-    this.#publish('configuration', {
+    this.#publish({
       kind: 'configuration.changed',
       revision: this.#configurationRevision,
     });
@@ -89,23 +83,49 @@ export class HostChangeFeed {
 
   publishProjectCatalog(): void {
     this.#projectCatalogRevision += 1;
-    this.#publish('project_catalog', {
+    this.#publish({
       kind: 'project.catalog.changed',
       revision: this.#projectCatalogRevision,
     });
   }
 
   publishSessionCatalog(sessionId: string): void {
+    this.#publishSessionCatalog(sessionId);
+  }
+
+  /** Publish the final scoped invalidation, then stop that resource-scoped feed. */
+  publishSessionCatalogAndCloseScope(sessionId: string, principalId: string): void {
+    this.#publishSessionCatalog(sessionId, principalId);
+  }
+
+  #publishSessionCatalog(sessionId: string, closeScopeFor?: string): void {
     this.#sessionCatalogRevision += 1;
-    this.#publish('session_catalog', {
+    const frame: SessionCatalogChangedFrame = {
       kind: 'session.catalog.changed',
       revision: this.#sessionCatalogRevision,
       sessionId,
-    });
+    };
+    for (const [connectionId, subscription] of this.#subscriptions) {
+      if (!isSubscribed(subscription.mask, frame)) continue;
+      void subscription.sink.send(frame).catch(() => {
+        if (this.#subscriptions.get(connectionId) === subscription) {
+          this.#subscriptions.delete(connectionId);
+        }
+      });
+      if (
+        closeScopeFor !== undefined &&
+        subscription.mask.sessionCatalog !== true &&
+        subscription.mask.sessionCatalog?.sessionId === sessionId &&
+        subscription.mask.sessionCatalog.principalId === closeScopeFor &&
+        this.#subscriptions.get(connectionId) === subscription
+      ) {
+        this.#subscriptions.delete(connectionId);
+      }
+    }
   }
 
   publishScheduledTask(revision: number, reason: ScheduledTaskChangedReason, taskId: string): void {
-    this.#publish('scheduled_task', {
+    this.#publish({
       kind: 'scheduled-task.changed',
       revision,
       reason,
@@ -113,9 +133,9 @@ export class HostChangeFeed {
     });
   }
 
-  #publish(kind: HostChangeKind, frame: HostChangeFrame): void {
+  #publish(frame: HostChangeFrame): void {
     for (const [connectionId, subscription] of this.#subscriptions) {
-      if (!isSubscribed(subscription.mask, kind)) continue;
+      if (!isSubscribed(subscription.mask, frame)) continue;
       void subscription.sink.send(frame).catch(() => {
         if (this.#subscriptions.get(connectionId) === subscription) {
           this.#subscriptions.delete(connectionId);
@@ -125,15 +145,18 @@ export class HostChangeFeed {
   }
 }
 
-function isSubscribed(mask: HostChangeSubscriptionMask, kind: HostChangeKind): boolean {
-  switch (kind) {
-    case 'configuration':
+function isSubscribed(mask: HostChangeSubscriptionMask, frame: HostChangeFrame): boolean {
+  switch (frame.kind) {
+    case 'configuration.changed':
       return mask.configuration === true;
-    case 'project_catalog':
+    case 'project.catalog.changed':
       return mask.projectCatalog === true;
-    case 'session_catalog':
-      return mask.sessionCatalog === true;
-    case 'scheduled_task':
+    case 'session.catalog.changed':
+      return (
+        mask.sessionCatalog === true ||
+        (mask.sessionCatalog !== undefined && frame.sessionId === mask.sessionCatalog.sessionId)
+      );
+    case 'scheduled-task.changed':
       return mask.scheduledTask === true;
   }
 }
diff --git a/packages/runtime-host/src/server/host-kernel.ts b/packages/runtime-host/src/server/host-kernel.ts
index 004bf6ad6..c0be10e94 100644
--- a/packages/runtime-host/src/server/host-kernel.ts
+++ b/packages/runtime-host/src/server/host-kernel.ts
@@ -217,6 +217,7 @@ export class RuntimeHostKernel {
   #resolveClosed!: () => void;
   #rejectClosed!: (error: unknown) => void;
   readonly #unsubscribeAccessRevocations: (() => void) | undefined;
+  readonly #unsubscribeSessionGrantRevocations: (() => void) | undefined;
 
   private constructor(options: RuntimeHostKernelOptions) {
     this.#lifecycle = normalizeLifecycle(options);
@@ -233,6 +234,16 @@ export class RuntimeHostKernel {
     this.#unsubscribeAccessRevocations = options.accessAuthority?.subscribeRevocations(
       (credentialId) => this.#revokeCredentialConnections(credentialId),
     );
+    this.#unsubscribeSessionGrantRevocations = options.accessAuthority?.subscribeGrantRevocations(
+      (grant) => {
+        if (grant.kind === 'session_observation') {
+          this.#composition?.hostChanges?.publishSessionCatalogAndCloseScope(
+            grant.sessionId,
+            grant.principalId,
+          );
+        }
+      },
+    );
     this.#operationHandlers = this.#createOperationHandlers(
       createUnavailableDomainOperationHandlers(),
     );
@@ -418,6 +429,11 @@ export class RuntimeHostKernel {
         resolveContinuity: () => this.#composition?.continuity,
         resolveClientCapabilities: () => this.#composition?.clientCapabilities,
         resolveHostChanges: () => this.#composition?.hostChanges,
+        resolveSharedSessionId: () =>
+          this.#options.accessAuthority?.activeSessionGrantForPrincipal(
+            authority.principalId,
+            'session_observation',
+          )?.sessionId,
         beginOperation: (request) => this.#beginOperation(request),
         onTeardown: releaseTransport,
       });
@@ -915,6 +931,7 @@ export class RuntimeHostKernel {
   async #closeResources(): Promise<void> {
     const errors: unknown[] = [];
     this.#unsubscribeAccessRevocations?.();
+    this.#unsubscribeSessionGrantRevocations?.();
     // Stop new admissions before any asynchronous shutdown bookkeeping. The
     // shutdown deadline may expire while publishing the draining registration;
     // leaving the listener open in that case strands an unreachable, ref'ed
@@ -968,6 +985,7 @@ export class RuntimeHostKernel {
   async #abortStartup(): Promise<void> {
     this.#state = 'draining';
     this.#unsubscribeAccessRevocations?.();
+    this.#unsubscribeSessionGrantRevocations?.();
     for (const transport of this.#handshakingTransports) transport.abort();
     for (const transport of this.#acceptedTransports) transport.abort();
     await this.#listeners?.closeAdmission().catch(() => undefined);
diff --git a/packages/ui/src/icons.tsx b/packages/ui/src/icons.tsx
index 5143904ce..7c9e29a03 100644
--- a/packages/ui/src/icons.tsx
+++ b/packages/ui/src/icons.tsx
@@ -143,6 +143,7 @@ export {
   Scan,
   Save,
   Search,
+  Share2,
   Settings,
   ShieldAlert,
   ShieldCheck,
diff --git a/packages/ui/src/session-history-list.tsx b/packages/ui/src/session-history-list.tsx
index efe2af78a..9e5b8a593 100644
--- a/packages/ui/src/session-history-list.tsx
+++ b/packages/ui/src/session-history-list.tsx
@@ -206,7 +206,9 @@ function SessionListGroups(props: {
         worktree={rail.worktreeSessionIds?.has(session.id) ?? false}
         meta={rail.sessionMeta?.(session)}
         onSelectSession={rail.onSelectSession}
-        actions={rail.rowActions}
+        actions={(session as SessionSummary & { readonly shared?: true }).shared
+          ? undefined
+          : rail.rowActions}
         onStartRename={startRename}
       />
     ),
diff --git a/packages/ui/src/titlebar-session-identity.tsx b/packages/ui/src/titlebar-session-identity.tsx
index 24b7d8ec6..6615a3c81 100644
--- a/packages/ui/src/titlebar-session-identity.tsx
+++ b/packages/ui/src/titlebar-session-identity.tsx
@@ -20,6 +20,9 @@
 import { useEffect, useRef, useState } from 'react';
 import { BreadcrumbItem, Breadcrumbs } from '@astryxdesign/core/Breadcrumbs';
 import { Icon } from '@astryxdesign/core/Icon';
+import { IconButton } from '@astryxdesign/core/IconButton';
+import { Tooltip } from '@astryxdesign/core/Tooltip';
+import { Share2 } from './icons.js';
 import { getConversationCopy } from './conversation-copy.js';
 import { InlineRenameInput } from './inline-rename-input.js';
 import { useUiLocale } from './locale-context.js';
@@ -94,6 +97,8 @@ export function TitlebarSessionIdentity(props: {
   project?: TitlebarProject;
   /** Immediate linked parent when viewing a child subagent session. */
   parentSession?: TitlebarParentSession;
+  readOnly?: boolean;
+  action?: { readonly label: string; onClick(): void };
 }) {
   const copy = getConversationCopy(useUiLocale());
   const [renaming, setRenaming] = useState(false);
@@ -197,6 +202,12 @@ export function TitlebarSessionIdentity(props: {
               onCancel={() => endRename(true)}
             />
           </BreadcrumbItem>
+        ) : props.readOnly ? (
+          <BreadcrumbItem isCurrent>
+            <span className="maka-titlebar-identity__segment maka-titlebar-identity__segment--session">
+              {props.sessionName}
+            </span>
+          </BreadcrumbItem>
         ) : (
           /* `isCurrent={false}`, not the default: a current crumb renders as a
              plain <span aria-current="page"> and DROPS onClick, so the rename
@@ -216,6 +227,18 @@ export function TitlebarSessionIdentity(props: {
           </BreadcrumbItem>
         )}
       </Breadcrumbs>
+      {props.action ? (
+        <Tooltip content={props.action.label}>
+          <IconButton
+            className="maka-titlebar-identity__action"
+            label={props.action.label}
+            icon={<Share2 size={14} />}
+            variant="ghost"
+            size="sm"
+            onClick={props.action.onClick}
+          />
+        </Tooltip>
+      ) : null}
     </div>
   );
 }
diff --git a/scripts/check-app-shell-hooks.mjs b/scripts/check-app-shell-hooks.mjs
index 0f16b26cc..3294e2b87 100644
--- a/scripts/check-app-shell-hooks.mjs
+++ b/scripts/check-app-shell-hooks.mjs
@@ -137,6 +137,7 @@ export const ALLOWED = {
     // replaces put three `useState`, four effects and a `useStableActions`
     // facade on this fiber.
     useSessionNavigationReads: 1,
+    useSessionCollaborationDialog: 1,
     useSessionSettingIntent: 2,
     useSettingsModal: 1,
     useShellAppearance: 1,


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


(maka) 03/04: feat(runtime-host): admit approved guest Turn requests (#4197)

Posted by wy...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

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

commit ce2052a355ccf0367f78e55325588e1fe75f1615
Author: Wang <m4...@gmail.com>
AuthorDate: Sun Aug 30 08:38:52 2026 +0800

    feat(runtime-host): admit approved guest Turn requests (#4197)
    
    Persist immutable Guest Turn requests and Owner decisions, then recover approved exact intents through the canonical Turn admission path.
    
    Generated-by: OpenAI Codex
---
 apps/desktop/src/main/runtime-host-client.ts       |  22 +
 .../runtime-host-operator-command.test.ts          |   2 +
 .../src/__tests__/root-turn-coordinator.test.ts    |  42 ++
 .../session-collaboration-authority.test.ts        | 532 ++++++++++++++++++++-
 packages/runtime-host/src/protocol/index.ts        |   4 +-
 packages/runtime-host/src/protocol/operations.ts   |   2 +
 .../src/protocol/session-collaboration.ts          | 274 +++++++++++
 packages/runtime-host/src/protocol/turn.ts         |   2 +-
 .../runtime-host/src/server/access-authority.ts    | 497 ++++++++++++++++++-
 .../src/server/access-credential-store.ts          |  55 ++-
 .../src/server/connection-authority.ts             |   6 +-
 .../src/server/execution-composition.ts            |  23 +-
 packages/runtime-host/src/server/host-kernel.ts    |  41 +-
 .../src/server/operation-dispatcher.ts             |  30 ++
 .../src/server/root-admission-owner.ts             |   4 +
 .../src/server/root-turn-coordinator.ts            |  23 +-
 .../session-turn-access-request-coordinator.ts     | 143 ++++++
 packages/storage/src/agent-run-store.ts            |  62 ++-
 packages/storage/src/execution-stores.ts           |   2 +
 19 files changed, 1724 insertions(+), 42 deletions(-)

diff --git a/apps/desktop/src/main/runtime-host-client.ts b/apps/desktop/src/main/runtime-host-client.ts
index 74ef6579d..235e4fe2b 100644
--- a/apps/desktop/src/main/runtime-host-client.ts
+++ b/apps/desktop/src/main/runtime-host-client.ts
@@ -108,7 +108,11 @@ import {
   type CollaborationAccessQueryResult,
   type CollaborationInvitationPrepareResult,
   type CollaborationPrincipalRevokeResult,
+  type CollaborationTurnRequestDecideResult,
+  type CollaborationTurnRequestQueryResult,
   type SessionCollaborationGrantKind,
+  type SessionTurnAccessRequest,
+  type SessionTurnRequestIntent,
   type SessionConfigurationPatch,
   type SessionAssistantStreamIdentity,
   type SessionContinuitySnapshot,
@@ -296,6 +300,24 @@ export class DesktopRuntimeHostClient {
     return this.request('collaboration.principal.revoke', { principalId });
   }
 
+  createCollaborationTurnRequest(intent: SessionTurnRequestIntent): Promise<SessionTurnAccessRequest> {
+    return this.request('collaboration.turn-request.create', { intent });
+  }
+
+  queryCollaborationTurnRequests(sessionId: string): Promise<CollaborationTurnRequestQueryResult> {
+    return this.request('collaboration.turn-request.query', { sessionId });
+  }
+
+  decideCollaborationTurnRequest(
+    requestId: string,
+    decision: 'approve' | 'reject',
+  ): Promise<CollaborationTurnRequestDecideResult> {
+    return this.request('collaboration.turn-request.decide', {
+      requestId,
+      decision,
+    });
+  }
+
   subscribeConfigurationChanges(listener: (revision: number) => void): () => void {
     this.#assertOpen();
     return this.connection.subscribeConfigurationChanges(listener);
diff --git a/packages/cli/src/__tests__/runtime-host-operator-command.test.ts b/packages/cli/src/__tests__/runtime-host-operator-command.test.ts
index b99cedecb..9179097b5 100644
--- a/packages/cli/src/__tests__/runtime-host-operator-command.test.ts
+++ b/packages/cli/src/__tests__/runtime-host-operator-command.test.ts
@@ -378,6 +378,8 @@ describe('Runtime Host operator commands', () => {
         'access.credential.rotation.prepare',
         'access.credential.rotation.revoke',
         'access.principal.revoke',
+        'collaboration.turn-request.acknowledge',
+        'collaboration.turn-request.create',
         'host.upgrade.prepare',
         'hosted.execution.cancel',
         'hosted.execution.start',
diff --git a/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts b/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts
index 791b9f8a6..3abf3d6db 100644
--- a/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts
+++ b/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts
@@ -826,6 +826,48 @@ test('turn.start durably applies one exact per-Turn orchestration override', asy
   }
 });
 
+test('turn.start durably binds a Guest request approval to the admitted Turn', async () => {
+  const fixture = await createFailureFixture({
+    registerBackend: (backends) =>
+      backends.register('ai-sdk', (context) => new FakeBackend(context)),
+  });
+  const input = {
+    sessionId: fixture.sessionId,
+    turnId: 'turn-collaboration-request',
+    content: { text: 'Run the exact approved request.' },
+  };
+  const authorization = {
+    kind: 'session_turn_access_request' as const,
+    requestId: 'request-1',
+    principalId: 'session_guest:guest-1',
+    grantId: 'grant-1',
+    approvedAt: 1_788_000_000_000,
+    approvedBy: 'local_owner',
+  };
+  try {
+    const started = await fixture.interactiveTurns.handlers['turn.start'](input, {
+      ...operationContext(fixture.hostEpoch, fixture.acquireResidency),
+      principal: authorization.principalId,
+      turnAdmissionAuthorization: authorization,
+    });
+    assertStartedTurn(started);
+    assert.deepEqual(
+      (await fixture.stores.agentRunStore.readRootTurnAdmission(fixture.sessionId, input.turnId))
+        ?.authorization,
+      authorization,
+    );
+
+    const conflictingRetry = await fixture.interactiveTurns.handlers['turn.start'](
+      input,
+      operationContext(fixture.hostEpoch, fixture.acquireResidency),
+    );
+    assert.equal(conflictingRetry.ok, false);
+    if (!conflictingRetry.ok) assert.equal(conflictingRetry.error.code, 'operation_conflict');
+  } finally {
+    await fixture.dispose();
+  }
+});
+
 test('turn.start resolves explicit Skills once before durable admission and replays the result', async () => {
   let preparationCount = 0;
   let blocked = false;
diff --git a/packages/runtime-host/src/__tests__/session-collaboration-authority.test.ts b/packages/runtime-host/src/__tests__/session-collaboration-authority.test.ts
index 9f3573e37..7faa13945 100644
--- a/packages/runtime-host/src/__tests__/session-collaboration-authority.test.ts
+++ b/packages/runtime-host/src/__tests__/session-collaboration-authority.test.ts
@@ -22,8 +22,31 @@ import { mkdtemp, rm } from 'node:fs/promises';
 import { tmpdir } from 'node:os';
 import { join } from 'node:path';
 import test from 'node:test';
-import { decodeCollaborationInvitationCode } from '../protocol/index.js';
-import { openRuntimeHostAccessAuthority } from '../server/access-authority.js';
+import {
+  decodeCollaborationInvitationCode,
+  HOST_OPERATION_SPECS,
+  type RequestFrame,
+} from '../protocol/index.js';
+import {
+  openRuntimeHostAccessAuthority,
+  queryCollaborationTurnRequests,
+  type RuntimeHostAccessAuthority,
+} from '../server/access-authority.js';
+import {
+  RuntimeHostAccessCommitOutcomeUnknownError,
+  writeAccessCredentialFile,
+} from '../server/access-credential-store.js';
+import { authorizeRuntimeHostOperation } from '../server/connection-authority.js';
+import { SessionTurnAccessRequestCoordinator } from '../server/session-turn-access-request-coordinator.js';
+
+const LOCAL_OWNER = {
+  principalId: 'local_owner',
+  principalKind: 'local_owner',
+} as const;
+
+function sessionGuest(principalId: string) {
+  return { principalId, principalKind: 'session_guest' as const };
+}
 
 test('Session Guest invitation, grants, and revocation form one durable authority lifecycle', async () => {
   const directory = await mkdtemp(join(tmpdir(), 'maka-session-collaboration-'));
@@ -42,9 +65,13 @@ test('Session Guest invitation, grants, and revocation form one durable authorit
     const credentialId = authority.authenticate(invitation.credential)?.credentialId;
     assert.ok(credentialId);
     await authority.finalize(credentialId, 'guest-client');
-    assert.deepEqual(authority.authenticate(invitation.credential)?.operationGrants, [
+    const activeGuest = authority.authenticate(invitation.credential);
+    assert.deepEqual(activeGuest?.operationGrants, [
       'host.status',
       'artifact.query',
+      'collaboration.turn-request.create',
+      'collaboration.turn-request.acknowledge',
+      'collaboration.turn-request.query',
       'runtime.resource.query',
       'session.shared.query',
       'subscription.open',
@@ -52,6 +79,36 @@ test('Session Guest invitation, grants, and revocation form one durable authorit
       'session.transcript.page',
       'session.transcript.overlay.release',
     ]);
+    assert.ok(activeGuest);
+    const unidentifiedQuery = queryCollaborationTurnRequests(
+      authority,
+      { principalId: activeGuest.principalId, principalKind: undefined },
+      { sessionId: 'session-1' },
+    );
+    assert.equal(unidentifiedQuery.ok, false);
+    if (!unidentifiedQuery.ok) assert.equal(unidentifiedQuery.error.code, 'operation_unavailable');
+    assert.equal(
+      authorizeRuntimeHostOperation(activeGuest, {
+        requestId: 'request-1',
+        operation: 'collaboration.turn-request.create',
+        input: {
+          intent: {
+            sessionId: 'session-1',
+            turnId: 'turn-1',
+            content: { text: 'Continue' },
+          },
+        },
+      } as RequestFrame),
+      true,
+    );
+    assert.equal(
+      authorizeRuntimeHostOperation(activeGuest, {
+        requestId: 'request-2',
+        operation: 'turn.start',
+        input: {},
+      } as RequestFrame),
+      false,
+    );
 
     const observation = prepared.grants.find((grant) => grant.kind === 'session_observation')!;
     assert.equal(
@@ -60,7 +117,11 @@ test('Session Guest invitation, grants, and revocation form one durable authorit
       observation.grantId,
     );
     assert.equal(
-      (await authority.revokeCollaborationGrant({ grantId: observation.grantId })).revoked,
+      (
+        await authority.revokeCollaborationGrant({
+          grantId: observation.grantId,
+        })
+      ).revoked,
       true,
     );
     assert.equal(
@@ -78,3 +139,466 @@ test('Session Guest invitation, grants, and revocation form one durable authorit
     await rm(directory, { recursive: true, force: true });
   }
 });
+
+test('Turn requests reject execution input that the Owner cannot review', () => {
+  assert.throws(
+    () =>
+      HOST_OPERATION_SPECS['collaboration.turn-request.create'].decodeInput({
+        intent: {
+          sessionId: 'session-1',
+          turnId: 'turn-1',
+          content: { text: 'Looks harmless' },
+          skillSelection: { mode: 'all' },
+        },
+      }),
+    /Unknown Session Turn request intent field/u,
+  );
+});
+
+test('an approved exact Turn request survives restart and is admitted once', async () => {
+  const directory = await mkdtemp(join(tmpdir(), 'maka-session-turn-request-'));
+  let authority = await openRuntimeHostAccessAuthority(directory);
+  try {
+    const principalId = await activateTurnGuest(authority);
+    const intent = {
+      sessionId: 'session-1',
+      turnId: 'turn-1',
+      content: { text: 'Run the approved task' },
+    };
+    const request = await authority.createTurnAccessRequest(principalId, {
+      intent,
+    });
+    const decision = await authority.decideTurnAccessRequest('local_owner', {
+      requestId: request.requestId,
+      decision: 'approve',
+    });
+    assert.equal(decision.kind, 'decided');
+    assert.equal(decision.kind, 'decided');
+    if (decision.kind !== 'decided') assert.fail('Expected an approved request');
+    assert.equal(decision.request.state.kind, 'approved');
+    if (decision.request.state.kind !== 'approved') assert.fail('Expected an approved request');
+    await authority.close();
+
+    authority = await openRuntimeHostAccessAuthority(directory);
+    const admitted: unknown[] = [];
+    const authorizations: unknown[] = [];
+    const coordinator = new SessionTurnAccessRequestCoordinator({
+      authority,
+      hostEpoch: 'epoch-1',
+      acquireResidency: () => ({ release: () => undefined }),
+      requestDrain: () => undefined,
+      whenIdle: () => undefined,
+      startTurn: async (input, context) => {
+        admitted.push(input);
+        authorizations.push(context.turnAdmissionAuthorization);
+        return {
+          ok: true,
+          result: {
+            kind: 'blocked',
+            skillInvocation: { loaded: [], failed: [], receipts: [] },
+          },
+        };
+      },
+    });
+    await coordinator.recover();
+    await coordinator.close();
+
+    assert.deepEqual(admitted, [intent]);
+    assert.deepEqual(authorizations, [
+      {
+        kind: 'session_turn_access_request',
+        requestId: request.requestId,
+        principalId,
+        grantId: request.grantId,
+        approvedAt: Date.parse(decision.request.state.decidedAt),
+        approvedBy: 'local_owner',
+      },
+    ]);
+    const completed = authority.queryTurnAccessRequests(LOCAL_OWNER, {
+      sessionId: 'session-1',
+    }).requests[0];
+    assert.equal(
+      completed?.state.kind === 'approved' ? completed.state.admission : undefined,
+      'blocked',
+    );
+  } finally {
+    await authority.close();
+    await rm(directory, { recursive: true, force: true });
+  }
+});
+
+test('Turn access request creation is idempotent for one exact Guest intent', async () => {
+  const directory = await mkdtemp(join(tmpdir(), 'maka-session-turn-request-idempotent-'));
+  const authority = await openRuntimeHostAccessAuthority(directory);
+  try {
+    const principalId = await activateTurnGuest(authority);
+    const intent = {
+      sessionId: 'session-1',
+      turnId: 'stable-turn',
+      content: { text: 'Run this once' },
+    };
+    const created = await authority.createTurnAccessRequest(principalId, { intent });
+    assert.deepEqual(await authority.createTurnAccessRequest(principalId, { intent }), created);
+    assert.equal(
+      authority.queryTurnAccessRequests(sessionGuest(principalId), { sessionId: 'session-1' })
+        .requests.length,
+      1,
+    );
+    await assert.rejects(
+      authority.createTurnAccessRequest(principalId, {
+        intent: { ...intent, content: { text: 'Different work' } },
+      }),
+      /different content/u,
+    );
+  } finally {
+    await authority.close();
+    await rm(directory, { recursive: true, force: true });
+  }
+});
+
+test('Turn access request results remain until acknowledged without consuming unbounded state', async () => {
+  const directory = await mkdtemp(join(tmpdir(), 'maka-session-turn-request-bound-'));
+  const authority = await openRuntimeHostAccessAuthority(directory);
+  try {
+    const principalId = await activateTurnGuest(authority);
+    const rejectedIds: string[] = [];
+    for (let index = 0; index < 4; index += 1) {
+      const request = await authority.createTurnAccessRequest(principalId, {
+        intent: {
+          sessionId: 'session-1',
+          turnId: `rejected-${index}`,
+          content: { text: `Rejected ${index}` },
+        },
+      });
+      await authority.decideTurnAccessRequest('local_owner', {
+        requestId: request.requestId,
+        decision: 'reject',
+      });
+      rejectedIds.push(request.requestId);
+    }
+    await assert.rejects(
+      authority.createTurnAccessRequest(principalId, {
+        intent: {
+          sessionId: 'session-1',
+          turnId: 'unacknowledged-overflow',
+          content: { text: 'Review the earlier results first' },
+        },
+      }),
+      /Review earlier Turn access request results/u,
+    );
+    assert.deepEqual(
+      authority
+        .queryTurnAccessRequests(sessionGuest(principalId), { sessionId: 'session-1' })
+        .requests.map((request) => request.requestId),
+      rejectedIds,
+    );
+    assert.deepEqual(
+      await authority.acknowledgeTurnAccessRequest(principalId, { requestId: rejectedIds[0]! }),
+      { acknowledged: true },
+    );
+    assert.equal(
+      authority.queryTurnAccessRequests(sessionGuest(principalId), { sessionId: 'session-1' })
+        .requests.length,
+      3,
+    );
+    const replacement = await authority.createTurnAccessRequest(principalId, {
+      intent: {
+        sessionId: 'session-1',
+        turnId: 'pending-after-acknowledgement',
+        content: { text: 'Acknowledged history no longer blocks new work' },
+      },
+    });
+    assert.equal(replacement.state.kind, 'pending');
+
+    const otherPrincipal = await activateTurnGuest(authority);
+    for (let index = 0; index < 3; index += 1) {
+      await authority.createTurnAccessRequest(otherPrincipal, {
+        intent: {
+          sessionId: 'session-1',
+          turnId: `pending-${index}`,
+          content: { text: `Pending ${index}` },
+        },
+      });
+    }
+    await assert.rejects(
+      authority.createTurnAccessRequest(otherPrincipal, {
+        intent: {
+          sessionId: 'session-1',
+          turnId: 'pending-overflow',
+          content: { text: 'Pending overflow' },
+        },
+      }),
+      /Too many Turn access requests/u,
+    );
+    assert.equal(
+      authority
+        .queryTurnAccessRequests(sessionGuest(principalId), { sessionId: 'session-1' })
+        .requests.every((request) => request.principalId === principalId),
+      true,
+    );
+    assert.equal(
+      authority
+        .queryTurnAccessRequests(LOCAL_OWNER, { sessionId: 'session-1' })
+        .requests.some((request) => request.principalId === otherPrincipal),
+      true,
+    );
+    await authority.revokeCollaborationPrincipal(otherPrincipal);
+    assert.equal(
+      authority
+        .queryTurnAccessRequests(LOCAL_OWNER, { sessionId: 'session-1' })
+        .requests.some((request) => request.principalId === otherPrincipal),
+      false,
+    );
+  } finally {
+    await authority.close();
+    await rm(directory, { recursive: true, force: true });
+  }
+});
+
+test('an uncertain access decision is recovered without premature Turn admission', async () => {
+  const directory = await mkdtemp(join(tmpdir(), 'maka-session-turn-request-uncertain-'));
+  let failAfterWrite = false;
+  let authority = await openRuntimeHostAccessAuthority(directory, {
+    writeFile: async (path, file) => {
+      await writeAccessCredentialFile(path, file);
+      if (failAfterWrite) throw new RuntimeHostAccessCommitOutcomeUnknownError(new Error('fsync'));
+    },
+  });
+  try {
+    const principalId = await activateTurnGuest(authority);
+    const request = await authority.createTurnAccessRequest(principalId, {
+      intent: {
+        sessionId: 'session-1',
+        turnId: 'turn-1',
+        content: { text: 'Run once durable' },
+      },
+    });
+    const published: string[] = [];
+    authority.subscribeApprovedTurnAccessRequests((changed) => published.push(changed.requestId));
+
+    failAfterWrite = true;
+    await assert.rejects(
+      authority.decideTurnAccessRequest('local_owner', {
+        requestId: request.requestId,
+        decision: 'approve',
+      }),
+      RuntimeHostAccessCommitOutcomeUnknownError,
+    );
+    assert.deepEqual(published, []);
+    await authority.close();
+
+    authority = await openRuntimeHostAccessAuthority(directory);
+    assert.equal(authority.approvedTurnAccessRequests().length, 1);
+  } finally {
+    await authority.close();
+    await rm(directory, { recursive: true, force: true });
+  }
+});
+
+test('recovery stays ready while an approved Turn request waits for the Session', async () => {
+  const directory = await mkdtemp(join(tmpdir(), 'maka-session-turn-request-busy-'));
+  const authority = await openRuntimeHostAccessAuthority(directory);
+  try {
+    const principalId = await activateTurnGuest(authority);
+    const request = await authority.createTurnAccessRequest(principalId, {
+      intent: {
+        sessionId: 'session-1',
+        turnId: 'turn-after-active-root',
+        content: { text: 'Start when the current Turn finishes' },
+      },
+    });
+    await authority.decideTurnAccessRequest('local_owner', {
+      requestId: request.requestId,
+      decision: 'approve',
+    });
+
+    let attempts = 0;
+    let drained = false;
+    let releaseIdle!: () => void;
+    const idle = new Promise<void>((resolve) => {
+      releaseIdle = resolve;
+    });
+    let observeSecondAttempt!: () => void;
+    const secondAttempt = new Promise<void>((resolve) => {
+      observeSecondAttempt = resolve;
+    });
+    const coordinator = new SessionTurnAccessRequestCoordinator({
+      authority,
+      hostEpoch: 'epoch-1',
+      acquireResidency: () => ({ release: () => undefined }),
+      requestDrain: () => {
+        drained = true;
+      },
+      whenIdle: () => idle,
+      startTurn: async () => {
+        attempts += 1;
+        if (attempts === 2) observeSecondAttempt();
+        return attempts === 1
+          ? {
+              ok: false,
+              error: {
+                code: 'session_busy',
+                message: 'Session already has an active root Turn',
+                retryable: true,
+              },
+            }
+          : {
+              ok: true,
+              result: {
+                kind: 'blocked',
+                skillInvocation: { loaded: [], failed: [], receipts: [] },
+              },
+            };
+      },
+    });
+    coordinator.recover();
+    assert.equal(attempts, 1);
+    releaseIdle();
+    await secondAttempt;
+    await coordinator.close();
+
+    assert.equal(attempts, 2);
+    assert.equal(drained, false);
+    const completed = authority.queryTurnAccessRequests(LOCAL_OWNER, {
+      sessionId: 'session-1',
+    }).requests[0];
+    assert.equal(
+      completed?.state.kind === 'approved' ? completed.state.admission : undefined,
+      'blocked',
+    );
+  } finally {
+    await authority.close();
+    await rm(directory, { recursive: true, force: true });
+  }
+});
+
+test('an idle-wait failure drains without losing the approved Turn request', async () => {
+  const directory = await mkdtemp(join(tmpdir(), 'maka-session-turn-request-idle-failure-'));
+  const authority = await openRuntimeHostAccessAuthority(directory);
+  try {
+    const principalId = await activateTurnGuest(authority);
+    const request = await authority.createTurnAccessRequest(principalId, {
+      intent: {
+        sessionId: 'session-1',
+        turnId: 'turn-after-failed-root',
+        content: { text: 'Retry after Host recovery' },
+      },
+    });
+    await authority.decideTurnAccessRequest('local_owner', {
+      requestId: request.requestId,
+      decision: 'approve',
+    });
+
+    let drained = false;
+    const coordinator = new SessionTurnAccessRequestCoordinator({
+      authority,
+      hostEpoch: 'epoch-1',
+      acquireResidency: () => ({ release: () => undefined }),
+      requestDrain: () => {
+        drained = true;
+      },
+      whenIdle: () => Promise.reject(new Error('active Turn authority failed')),
+      startTurn: async () => ({
+        ok: false,
+        error: {
+          code: 'session_busy',
+          message: 'Session already has an active root Turn',
+          retryable: true,
+        },
+      }),
+    });
+    coordinator.recover();
+    await coordinator.close();
+
+    assert.equal(drained, true);
+    const pending = authority.queryTurnAccessRequests(LOCAL_OWNER, {
+      sessionId: 'session-1',
+    }).requests[0];
+    assert.equal(
+      pending?.state.kind === 'approved' ? pending.state.admission : undefined,
+      'pending',
+    );
+  } finally {
+    await authority.close();
+    await rm(directory, { recursive: true, force: true });
+  }
+});
+
+test('drain does not terminalize an in-flight admission failure', async () => {
+  const directory = await mkdtemp(join(tmpdir(), 'maka-session-turn-request-drain-'));
+  const authority = await openRuntimeHostAccessAuthority(directory);
+  try {
+    const principalId = await activateTurnGuest(authority);
+    let finishAdmission!: (result: {
+      ok: false;
+      error: { code: 'host_draining'; message: string; retryable: boolean };
+    }) => void;
+    const admission = new Promise<{
+      ok: false;
+      error: { code: 'host_draining'; message: string; retryable: boolean };
+    }>((resolve) => {
+      finishAdmission = resolve;
+    });
+    let started!: () => void;
+    const startObserved = new Promise<void>((resolve) => {
+      started = resolve;
+    });
+    const coordinator = new SessionTurnAccessRequestCoordinator({
+      authority,
+      hostEpoch: 'epoch-1',
+      acquireResidency: () => ({ release: () => undefined }),
+      requestDrain: () => undefined,
+      whenIdle: () => undefined,
+      startTurn: async () => {
+        started();
+        return admission;
+      },
+    });
+    const request = await authority.createTurnAccessRequest(principalId, {
+      intent: {
+        sessionId: 'session-1',
+        turnId: 'turn-during-drain',
+        content: { text: 'Retry after restart' },
+      },
+    });
+    await authority.decideTurnAccessRequest('local_owner', {
+      requestId: request.requestId,
+      decision: 'approve',
+    });
+    await startObserved;
+    coordinator.beginDrain();
+    finishAdmission({
+      ok: false,
+      error: {
+        code: 'host_draining',
+        message: 'Runtime Host is draining',
+        retryable: true,
+      },
+    });
+    await coordinator.close();
+
+    const retained = authority.queryTurnAccessRequests(LOCAL_OWNER, {
+      sessionId: 'session-1',
+    }).requests[0];
+    assert.equal(retained?.state.kind, 'approved');
+    assert.equal(
+      retained?.state.kind === 'approved' ? retained.state.admission : undefined,
+      'pending',
+    );
+  } finally {
+    await authority.close();
+    await rm(directory, { recursive: true, force: true });
+  }
+});
+
+async function activateTurnGuest(authority: RuntimeHostAccessAuthority): Promise<string> {
+  const prepared = await authority.prepareCollaborationInvitation('root-1', {
+    sessionId: 'session-1',
+    grantKinds: ['session_turn_request'],
+  });
+  const invitation = decodeCollaborationInvitationCode(prepared.invitationCode);
+  const credentialId = authority.authenticate(invitation.credential)?.credentialId;
+  assert.ok(credentialId);
+  await authority.finalize(credentialId, 'guest-client');
+  return prepared.principalId;
+}
diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts
index 9b1663234..651871f71 100644
--- a/packages/runtime-host/src/protocol/index.ts
+++ b/packages/runtime-host/src/protocol/index.ts
@@ -95,7 +95,9 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const;
 export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const;
 // Increment when the same protocol version no longer guarantees safe Client-Host
 // interoperability. Mismatches are rejected before domain commands are admitted.
-export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 70 as const;
+export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 71 as const;
+// 71: Session Guests can submit durable exact Turn access requests and Owners
+// can decide them. Older peers do not understand this execution-authority flow.
 // 70: Session Guest connections receive resource-scoped shared catalog and
 // continuity projections. Older peers cannot enforce the Session grant fence.
 // 69: Runtime Host access authority recognizes restricted Session Guest
diff --git a/packages/runtime-host/src/protocol/operations.ts b/packages/runtime-host/src/protocol/operations.ts
index ed7af8d93..847692796 100644
--- a/packages/runtime-host/src/protocol/operations.ts
+++ b/packages/runtime-host/src/protocol/operations.ts
@@ -241,6 +241,8 @@ export const REMOTE_OWNER_OPERATION_GRANTS = Object.freeze([
   'collaboration.grant.revoke',
   'collaboration.invitation.prepare',
   'collaboration.principal.revoke',
+  'collaboration.turn-request.decide',
+  'collaboration.turn-request.query',
   'connection.catalog.create',
   'connection.catalog.query',
   'connection.catalog.remove',
diff --git a/packages/runtime-host/src/protocol/session-collaboration.ts b/packages/runtime-host/src/protocol/session-collaboration.ts
index 25cdb43ea..df0c4a377 100644
--- a/packages/runtime-host/src/protocol/session-collaboration.ts
+++ b/packages/runtime-host/src/protocol/session-collaboration.ts
@@ -21,11 +21,13 @@ import {
   requireEntityId,
   requireExactRecord,
   requireId,
+  requireRecord,
   requireShapedRecord,
   requireUtf8String,
 } from './codec.js';
 import { invalidProtocolFrame } from './errors.js';
 import { defineOperation } from './operation-spec.js';
+import { decodeTurnStartInput } from './turn.js';
 
 export const COLLABORATION_INVITATION_SCHEMA_VERSION = 1 as const;
 export const COLLABORATION_INVITATION_CODE_MAX_BYTES = 16 * 1024;
@@ -100,6 +102,66 @@ export interface CollaborationPrincipalRevokeResult {
   readonly revoked: boolean;
 }
 
+export type SessionTurnAccessRequestState =
+  | { readonly kind: 'pending' }
+  | {
+      readonly kind: 'rejected';
+      readonly decidedAt: string;
+      readonly decidedBy: string;
+    }
+  | {
+      readonly kind: 'approved';
+      readonly decidedAt: string;
+      readonly decidedBy: string;
+      readonly admission: 'pending' | 'started' | 'blocked' | 'failed';
+    };
+
+export interface SessionTurnRequestIntent {
+  readonly sessionId: string;
+  readonly turnId: string;
+  readonly content: { readonly text: string };
+}
+
+export interface SessionTurnAccessRequest {
+  readonly requestId: string;
+  readonly principalId: string;
+  readonly grantId: string;
+  readonly intent: SessionTurnRequestIntent;
+  readonly createdAt: string;
+  readonly state: SessionTurnAccessRequestState;
+}
+
+export interface CollaborationTurnRequestCreateInput {
+  readonly intent: SessionTurnRequestIntent;
+}
+
+export interface CollaborationTurnRequestQueryInput {
+  readonly sessionId: string;
+}
+
+export interface CollaborationTurnRequestQueryResult {
+  readonly requests: readonly SessionTurnAccessRequest[];
+}
+
+export interface CollaborationTurnRequestDecideInput {
+  readonly requestId: string;
+  readonly decision: 'approve' | 'reject';
+}
+
+export type CollaborationTurnRequestDecideResult =
+  | { readonly kind: 'not_found' }
+  | {
+      readonly kind: 'decided' | 'already_decided';
+      readonly request: SessionTurnAccessRequest;
+    };
+
+export interface CollaborationTurnRequestAcknowledgeInput {
+  readonly requestId: string;
+}
+
+export interface CollaborationTurnRequestAcknowledgeResult {
+  readonly acknowledged: boolean;
+}
 const COLLABORATION_ERRORS = [
   'host_not_ready',
   'host_draining',
@@ -155,6 +217,50 @@ export const SESSION_COLLABORATION_OPERATION_SPECS = {
     decodeInput: decodeCollaborationPrincipalRevokeInput,
     decodeOutput: decodeCollaborationPrincipalRevokeResult,
   }),
+  'collaboration.turn-request.create': defineOperation<
+    CollaborationTurnRequestCreateInput,
+    SessionTurnAccessRequest,
+    (typeof COLLABORATION_ERRORS)[number]
+  >({
+    mode: 'command',
+    availability: 'ready',
+    errors: COLLABORATION_ERRORS,
+    decodeInput: decodeCollaborationTurnRequestCreateInput,
+    decodeOutput: decodeSessionTurnAccessRequest,
+  }),
+  'collaboration.turn-request.query': defineOperation<
+    CollaborationTurnRequestQueryInput,
+    CollaborationTurnRequestQueryResult,
+    (typeof COLLABORATION_ERRORS)[number]
+  >({
+    mode: 'query',
+    availability: 'ready',
+    errors: COLLABORATION_ERRORS,
+    decodeInput: decodeCollaborationTurnRequestQueryInput,
+    decodeOutput: decodeCollaborationTurnRequestQueryResult,
+  }),
+  'collaboration.turn-request.acknowledge': defineOperation<
+    CollaborationTurnRequestAcknowledgeInput,
+    CollaborationTurnRequestAcknowledgeResult,
+    (typeof COLLABORATION_ERRORS)[number]
+  >({
+    mode: 'command',
+    availability: 'ready',
+    errors: COLLABORATION_ERRORS,
+    decodeInput: decodeCollaborationTurnRequestAcknowledgeInput,
+    decodeOutput: decodeCollaborationTurnRequestAcknowledgeResult,
+  }),
+  'collaboration.turn-request.decide': defineOperation<
+    CollaborationTurnRequestDecideInput,
+    CollaborationTurnRequestDecideResult,
+    (typeof COLLABORATION_ERRORS)[number]
+  >({
+    mode: 'command',
+    availability: 'ready',
+    errors: COLLABORATION_ERRORS,
+    decodeInput: decodeCollaborationTurnRequestDecideInput,
+    decodeOutput: decodeCollaborationTurnRequestDecideResult,
+  }),
 } as const;
 
 export function encodeCollaborationInvitationCode(payload: CollaborationInvitationPayload): string {
@@ -307,6 +413,174 @@ function decodeCollaborationPrincipalRevokeResult(
   return { revoked: record.revoked };
 }
 
+function decodeCollaborationTurnRequestCreateInput(
+  value: unknown,
+): CollaborationTurnRequestCreateInput {
+  const record = requireExactRecord(value, 'collaboration Turn request input', ['intent']);
+  return { intent: decodeSessionTurnRequestIntent(record.intent) };
+}
+
+function decodeCollaborationTurnRequestQueryInput(
+  value: unknown,
+): CollaborationTurnRequestQueryInput {
+  const record = requireExactRecord(value, 'collaboration Turn request query', ['sessionId']);
+  return { sessionId: requireEntityId(record.sessionId, 'sessionId') };
+}
+
+function decodeCollaborationTurnRequestQueryResult(
+  value: unknown,
+): CollaborationTurnRequestQueryResult {
+  const record = requireExactRecord(value, 'collaboration Turn request query result', ['requests']);
+  if (!Array.isArray(record.requests)) {
+    throw invalidProtocolFrame('Invalid collaboration Turn request query result');
+  }
+  return { requests: record.requests.map(decodeSessionTurnAccessRequest) };
+}
+
+function decodeCollaborationTurnRequestAcknowledgeInput(
+  value: unknown,
+): CollaborationTurnRequestAcknowledgeInput {
+  const record = requireExactRecord(value, 'collaboration Turn request acknowledgement input', [
+    'requestId',
+  ]);
+  return { requestId: requireId(record.requestId, 'requestId') };
+}
+
+function decodeCollaborationTurnRequestAcknowledgeResult(
+  value: unknown,
+): CollaborationTurnRequestAcknowledgeResult {
+  const record = requireExactRecord(value, 'collaboration Turn request acknowledgement result', [
+    'acknowledged',
+  ]);
+  if (typeof record.acknowledged !== 'boolean') {
+    throw invalidProtocolFrame('Invalid collaboration Turn request acknowledgement result');
+  }
+  return { acknowledged: record.acknowledged };
+}
+
+function decodeCollaborationTurnRequestDecideInput(
+  value: unknown,
+): CollaborationTurnRequestDecideInput {
+  const record = requireExactRecord(value, 'collaboration Turn request decision', [
+    'requestId',
+    'decision',
+  ]);
+  if (record.decision !== 'approve' && record.decision !== 'reject') {
+    throw invalidProtocolFrame('Invalid collaboration Turn request decision');
+  }
+  return {
+    requestId: requireId(record.requestId, 'requestId'),
+    decision: record.decision,
+  };
+}
+
+function decodeCollaborationTurnRequestDecideResult(
+  value: unknown,
+): CollaborationTurnRequestDecideResult {
+  const candidate = requireRecord(value, 'collaboration Turn request decision result');
+  const record = requireExactRecord(
+    value,
+    'collaboration Turn request decision result',
+    candidate.kind === 'not_found' ? ['kind'] : ['kind', 'request'],
+  );
+  if (
+    record.kind !== 'decided' &&
+    record.kind !== 'already_decided' &&
+    record.kind !== 'not_found'
+  ) {
+    throw invalidProtocolFrame('Invalid collaboration Turn request decision result');
+  }
+  return record.kind === 'not_found'
+    ? { kind: record.kind }
+    : { kind: record.kind, request: decodeSessionTurnAccessRequest(record.request) };
+}
+
+export function decodeSessionTurnAccessRequest(value: unknown): SessionTurnAccessRequest {
+  const record = requireExactRecord(value, 'Session Turn access request', [
+    'requestId',
+    'principalId',
+    'grantId',
+    'intent',
+    'createdAt',
+    'state',
+  ]);
+  return {
+    requestId: requireId(record.requestId, 'requestId'),
+    principalId: decodePrincipalId(record.principalId),
+    grantId: requireId(record.grantId, 'grantId'),
+    intent: decodeSessionTurnRequestIntent(record.intent),
+    createdAt: decodeIsoTimestamp(record.createdAt, 'createdAt'),
+    state: decodeSessionTurnAccessRequestState(record.state),
+  };
+}
+
+function decodeSessionTurnRequestIntent(value: unknown): SessionTurnRequestIntent {
+  const record = requireExactRecord(value, 'Session Turn request intent', [
+    'sessionId',
+    'turnId',
+    'content',
+  ]);
+  const content = requireExactRecord(record.content, 'Session Turn request content', ['text']);
+  const decoded = decodeTurnStartInput({
+    sessionId: record.sessionId,
+    turnId: record.turnId,
+    content: { text: content.text },
+  });
+  return {
+    sessionId: decoded.sessionId,
+    turnId: decoded.turnId,
+    content: { text: decoded.content.text },
+  };
+}
+
+function decodeSessionTurnAccessRequestState(value: unknown): SessionTurnAccessRequestState {
+  const kind = requireShapedRecord(
+    value,
+    'Session Turn access request state',
+    ['kind'],
+    ['decidedAt', 'decidedBy', 'admission'],
+  ).kind;
+  if (kind === 'pending') {
+    requireExactRecord(value, 'pending Session Turn access request', ['kind']);
+    return { kind };
+  }
+  if (kind === 'rejected') {
+    const record = requireExactRecord(value, 'rejected Session Turn access request', [
+      'kind',
+      'decidedAt',
+      'decidedBy',
+    ]);
+    return {
+      kind,
+      decidedAt: decodeIsoTimestamp(record.decidedAt, 'decidedAt'),
+      decidedBy: decodePrincipalId(record.decidedBy),
+    };
+  }
+  if (kind !== 'approved') {
+    throw invalidProtocolFrame('Invalid Session Turn access request state');
+  }
+  const candidate = requireExactRecord(value, 'approved Session Turn access request', [
+    'kind',
+    'decidedAt',
+    'decidedBy',
+    'admission',
+  ]);
+  if (
+    candidate.admission !== 'pending' &&
+    candidate.admission !== 'started' &&
+    candidate.admission !== 'blocked' &&
+    candidate.admission !== 'failed'
+  ) {
+    throw invalidProtocolFrame('Invalid Session Turn access request admission');
+  }
+  return {
+    kind,
+    decidedAt: decodeIsoTimestamp(candidate.decidedAt, 'decidedAt'),
+    decidedBy: decodePrincipalId(candidate.decidedBy),
+    admission: candidate.admission,
+  };
+}
+
 export function decodeSessionCollaborationGrant(value: unknown): SessionCollaborationGrant {
   const record = requireExactRecord(value, 'Session collaboration grant', [
     'kind',
diff --git a/packages/runtime-host/src/protocol/turn.ts b/packages/runtime-host/src/protocol/turn.ts
index e1a4a5b41..def4e1a4b 100644
--- a/packages/runtime-host/src/protocol/turn.ts
+++ b/packages/runtime-host/src/protocol/turn.ts
@@ -344,7 +344,7 @@ export const TURN_OPERATION_SPECS = {
   }),
 } as const;
 
-function decodeTurnStartInput(value: unknown): TurnStartInput {
+export function decodeTurnStartInput(value: unknown): TurnStartInput {
   const record = requireShapedRecord(
     value,
     'turn.start input',
diff --git a/packages/runtime-host/src/server/access-authority.ts b/packages/runtime-host/src/server/access-authority.ts
index 3ccdfad22..000a8868f 100644
--- a/packages/runtime-host/src/server/access-authority.ts
+++ b/packages/runtime-host/src/server/access-authority.ts
@@ -43,9 +43,17 @@ import {
   type CollaborationInvitationPrepareInput,
   type CollaborationInvitationPrepareResult,
   type CollaborationPrincipalRevokeResult,
+  type CollaborationTurnRequestAcknowledgeInput,
+  type CollaborationTurnRequestAcknowledgeResult,
+  type CollaborationTurnRequestCreateInput,
+  type CollaborationTurnRequestDecideInput,
+  type CollaborationTurnRequestDecideResult,
+  type CollaborationTurnRequestQueryInput,
+  type CollaborationTurnRequestQueryResult,
   encodeCollaborationInvitationCode,
   type SessionCollaborationGrant,
   type SessionCollaborationGrantKind,
+  type SessionTurnAccessRequest,
 } from '../protocol/index.js';
 import {
   createRuntimeHostConnectionAuthority,
@@ -73,12 +81,22 @@ import {
 
 const ACCESS_CREDENTIAL_PREFIX = 'maka_rh_';
 const PENDING_CREDENTIAL_LIFETIME_MS = 15 * 60_000;
+const TURN_ACCESS_REQUEST_ACTIVE_MAX = 4;
 const CAPABILITY_PROVIDER_GRANTS = new Set([
   'host.status',
   'client.capability.replace',
   'client.capability.unregister',
 ]);
 
+function createNextAccessCredentialFile(
+  current: AccessCredentialFile,
+  credentials: readonly StoredAccessCredential[],
+  sessionGrants: readonly SessionCollaborationGrant[],
+  turnAccessRequests: readonly SessionTurnAccessRequest[] = current.turnAccessRequests,
+): AccessCredentialFile {
+  return createAccessCredentialFile(credentials, sessionGrants, turnAccessRequests);
+}
+
 export interface RuntimeHostAccessAuthority {
   authenticate(credential: string): RuntimeHostConnectionAuthority | undefined;
   issue(input: AccessCredentialIssueInput): Promise<AccessCredentialIssueResult>;
@@ -102,6 +120,27 @@ export interface RuntimeHostAccessAuthority {
     input: CollaborationGrantRevokeInput,
   ): Promise<CollaborationGrantRevokeResult>;
   revokeCollaborationPrincipal(principalId: string): Promise<CollaborationPrincipalRevokeResult>;
+  createTurnAccessRequest(
+    principalId: string,
+    input: CollaborationTurnRequestCreateInput,
+  ): Promise<SessionTurnAccessRequest>;
+  queryTurnAccessRequests(
+    principal: Pick<RuntimeHostConnectionAuthority, 'principalId' | 'principalKind'>,
+    input: CollaborationTurnRequestQueryInput,
+  ): CollaborationTurnRequestQueryResult;
+  acknowledgeTurnAccessRequest(
+    principalId: string,
+    input: CollaborationTurnRequestAcknowledgeInput,
+  ): Promise<CollaborationTurnRequestAcknowledgeResult>;
+  decideTurnAccessRequest(
+    principalId: string,
+    input: CollaborationTurnRequestDecideInput,
+  ): Promise<CollaborationTurnRequestDecideResult>;
+  completeTurnAccessRequest(
+    requestId: string,
+    admission: 'started' | 'blocked' | 'failed',
+  ): Promise<void>;
+  approvedTurnAccessRequests(): readonly SessionTurnAccessRequest[];
   activeSessionGrant(
     principalId: string,
     sessionId: string,
@@ -113,6 +152,9 @@ export interface RuntimeHostAccessAuthority {
   ): SessionCollaborationGrant | undefined;
   subscribeRevocations(listener: (credentialId: string) => void): () => void;
   subscribeGrantRevocations(listener: (grant: SessionCollaborationGrant) => void): () => void;
+  subscribeApprovedTurnAccessRequests(
+    listener: (request: SessionTurnAccessRequest) => void,
+  ): () => void;
   close(): Promise<void>;
 }
 
@@ -142,6 +184,9 @@ class FileRuntimeHostAccessAuthority implements RuntimeHostAccessAuthority {
   #closed = false;
   readonly #revocationListeners = new Set<(credentialId: string) => void>();
   readonly #grantRevocationListeners = new Set<(grant: SessionCollaborationGrant) => void>();
+  readonly #approvedTurnAccessRequestListeners = new Set<
+    (request: SessionTurnAccessRequest) => void
+  >();
 
   constructor(
     controlDirectory: string,
@@ -233,7 +278,8 @@ class FileRuntimeHostAccessAuthority implements RuntimeHostAccessAuthority {
           createdAt,
         }),
       );
-      const nextFile = createAccessCredentialFile(
+      const nextFile = createNextAccessCredentialFile(
+        this.#file,
         [...this.#file.credentials, stored],
         [...this.#file.sessionGrants, ...grants],
       );
@@ -290,9 +336,16 @@ class FileRuntimeHostAccessAuthority implements RuntimeHostAccessAuthority {
       const current = this.#file.sessionGrants.find((grant) => grant.grantId === input.grantId);
       if (!current) return { revoked: false };
       await this.#commit(
-        createAccessCredentialFile(
+        createNextAccessCredentialFile(
+          this.#file,
           this.#file.credentials,
           this.#file.sessionGrants.filter((grant) => grant !== current),
+          current.kind === 'session_turn_request'
+            ? removePendingTurnAccessRequests(
+                this.#file.turnAccessRequests,
+                (request) => request.grantId === current.grantId,
+              )
+            : this.#file.turnAccessRequests,
         ),
         [],
         [current],
@@ -302,7 +355,204 @@ class FileRuntimeHostAccessAuthority implements RuntimeHostAccessAuthority {
   }
 
   revokeCollaborationPrincipal(principalId: string): Promise<CollaborationPrincipalRevokeResult> {
-    return this.revokePrincipal({ principalKind: 'session_guest', principalId });
+    return this.revokePrincipal({
+      principalKind: 'session_guest',
+      principalId,
+    });
+  }
+
+  createTurnAccessRequest(
+    principalId: string,
+    input: CollaborationTurnRequestCreateInput,
+  ): Promise<SessionTurnAccessRequest> {
+    return this.#mutate(async () => {
+      const grant = this.activeSessionGrant(
+        principalId,
+        input.intent.sessionId,
+        'session_turn_request',
+      );
+      if (!grant) {
+        throw new RuntimeHostAccessInputError('This Guest cannot request a Turn in this Session');
+      }
+      const retainedRequests = this.#file.turnAccessRequests;
+      const existing = retainedRequests.find(
+        (request) =>
+          request.principalId === principalId &&
+          request.intent.sessionId === input.intent.sessionId &&
+          request.intent.turnId === input.intent.turnId,
+      );
+      if (existing) {
+        if (existing.intent.content.text !== input.intent.content.text) {
+          throw new RuntimeHostAccessInputError(
+            'A Turn access request already uses this Turn identity with different content',
+          );
+        }
+        return existing;
+      }
+      if (
+        retainedRequests.filter(isActiveTurnAccessRequest).length >= TURN_ACCESS_REQUEST_ACTIVE_MAX
+      ) {
+        throw new RuntimeHostAccessInputError(
+          'Too many Turn access requests are awaiting an Owner decision or admission',
+        );
+      }
+      if (
+        retainedRequests.filter(
+          (request) => request.principalId === principalId && !isActiveTurnAccessRequest(request),
+        ).length >= TURN_ACCESS_REQUEST_ACTIVE_MAX
+      ) {
+        throw new RuntimeHostAccessInputError(
+          'Review earlier Turn access request results before creating another request',
+        );
+      }
+      const request: SessionTurnAccessRequest = {
+        requestId: randomUUID(),
+        principalId,
+        grantId: grant.grantId,
+        intent: input.intent,
+        createdAt: new Date().toISOString(),
+        state: { kind: 'pending' },
+      };
+      const nextFile = createAccessCredentialFile(
+        this.#file.credentials,
+        this.#file.sessionGrants,
+        [...retainedRequests, request],
+      );
+      assertAccessCredentialFileCapacity(nextFile);
+      await this.#commit(nextFile);
+      return request;
+    });
+  }
+
+  queryTurnAccessRequests(
+    principal: Pick<RuntimeHostConnectionAuthority, 'principalId' | 'principalKind'>,
+    input: CollaborationTurnRequestQueryInput,
+  ): CollaborationTurnRequestQueryResult {
+    const guest = principal.principalKind === 'session_guest';
+    return {
+      requests: this.#file.turnAccessRequests.filter(
+        (request) =>
+          (!guest || request.principalId === principal.principalId) &&
+          request.intent.sessionId === input.sessionId,
+      ),
+    };
+  }
+
+  acknowledgeTurnAccessRequest(
+    principalId: string,
+    input: CollaborationTurnRequestAcknowledgeInput,
+  ): Promise<CollaborationTurnRequestAcknowledgeResult> {
+    return this.#mutate(async () => {
+      const current = this.#file.turnAccessRequests.find(
+        (request) => request.requestId === input.requestId && request.principalId === principalId,
+      );
+      if (
+        !current ||
+        current.state.kind === 'pending' ||
+        (current.state.kind === 'approved' && current.state.admission === 'pending')
+      ) {
+        return { acknowledged: false };
+      }
+      await this.#commit(
+        createAccessCredentialFile(
+          this.#file.credentials,
+          this.#file.sessionGrants,
+          this.#file.turnAccessRequests.filter((request) => request !== current),
+        ),
+      );
+      return { acknowledged: true };
+    });
+  }
+
+  decideTurnAccessRequest(
+    principalId: string,
+    input: CollaborationTurnRequestDecideInput,
+  ): Promise<CollaborationTurnRequestDecideResult> {
+    return this.#mutate(async () => {
+      const current = this.#file.turnAccessRequests.find(
+        (request) => request.requestId === input.requestId,
+      );
+      if (!current) return { kind: 'not_found' };
+      if (current.state.kind !== 'pending') {
+        return { kind: 'already_decided', request: current };
+      }
+      if (
+        input.decision === 'approve' &&
+        !this.activeSessionGrant(
+          current.principalId,
+          current.intent.sessionId,
+          'session_turn_request',
+        )
+      ) {
+        throw new RuntimeHostAccessInputError('The Guest can no longer request this Turn');
+      }
+      const decidedAt = new Date().toISOString();
+      const request: SessionTurnAccessRequest = {
+        ...current,
+        state:
+          input.decision === 'approve'
+            ? {
+                kind: 'approved',
+                decidedAt,
+                decidedBy: principalId,
+                admission: 'pending',
+              }
+            : { kind: 'rejected', decidedAt, decidedBy: principalId },
+      };
+      await this.#commit(
+        createAccessCredentialFile(
+          this.#file.credentials,
+          this.#file.sessionGrants,
+          replaceTurnAccessRequest(this.#file.turnAccessRequests, current, request),
+        ),
+        [],
+        [],
+        input.decision === 'approve' ? [request] : [],
+      );
+      return { kind: 'decided', request };
+    });
+  }
+
+  completeTurnAccessRequest(
+    requestId: string,
+    admission: 'started' | 'blocked' | 'failed',
+  ): Promise<void> {
+    return this.#mutate(async () => {
+      const current = this.#file.turnAccessRequests.find(
+        (request) => request.requestId === requestId,
+      );
+      if (!current || current.state.kind !== 'approved' || current.state.admission !== 'pending') {
+        return;
+      }
+      const guestCanObserve = this.#file.credentials.some(
+        (credential) =>
+          credential.principalKind === 'session_guest' &&
+          credential.principalId === current.principalId &&
+          credential.status !== 'revoked',
+      );
+      const request: SessionTurnAccessRequest = {
+        ...current,
+        state: {
+          ...current.state,
+          admission,
+        },
+      };
+      await this.#commit(
+        createAccessCredentialFile(
+          this.#file.credentials,
+          this.#file.sessionGrants,
+          guestCanObserve
+            ? replaceTurnAccessRequest(this.#file.turnAccessRequests, current, request)
+            : this.#file.turnAccessRequests.filter((candidate) => candidate !== current),
+        ),
+      );
+    });
+  }
+
+  approvedTurnAccessRequests(): readonly SessionTurnAccessRequest[] {
+    return this.#file.turnAccessRequests.filter(
+      (request) => request.state.kind === 'approved' && request.state.admission === 'pending',
+    );
   }
 
   activeSessionGrant(
@@ -418,7 +668,11 @@ class FileRuntimeHostAccessAuthority implements RuntimeHostAccessAuthority {
       replaced.length === 0
         ? this.#file.credentials
         : this.#file.credentials.filter((candidate) => !replaced.includes(candidate));
-    const nextFile = createAccessCredentialFile([...retained, stored], this.#file.sessionGrants);
+    const nextFile = createNextAccessCredentialFile(
+      this.#file,
+      [...retained, stored],
+      this.#file.sessionGrants,
+    );
     assertAccessCredentialFileCapacity(nextFile);
     const deliveryId = await createAccessCredentialDelivery(
       this.#controlDirectory,
@@ -474,9 +728,13 @@ class FileRuntimeHostAccessAuthority implements RuntimeHostAccessAuthority {
         return [{ ...revoked, status: 'revoked' as const, revokedAt }];
       });
       await this.#commit(
-        createAccessCredentialFile(
+        createNextAccessCredentialFile(
+          this.#file,
           credentials,
           this.#file.sessionGrants.filter((grant) => !activeGrants.includes(grant)),
+          input.principalKind === 'session_guest'
+            ? retirePrincipalTurnAccessRequests(this.#file.turnAccessRequests, input.principalId)
+            : this.#file.turnAccessRequests,
         ),
         [...matchedIds],
         activeGrants,
@@ -536,7 +794,7 @@ class FileRuntimeHostAccessAuthority implements RuntimeHostAccessAuthority {
               };
             });
     await this.#commit(
-      createAccessCredentialFile(credentials, this.#file.sessionGrants),
+      createNextAccessCredentialFile(this.#file, credentials, this.#file.sessionGrants),
       [current, ...pendingForPrincipal].map((credential) => credential.credentialId),
     );
     return { credentialId, revoked: true };
@@ -580,7 +838,8 @@ class FileRuntimeHostAccessAuthority implements RuntimeHostAccessAuthority {
         credential.principalKind === retained.principalKind &&
         credential.principalId === retained.principalId,
     );
-    const finalized = createAccessCredentialFile(
+    const finalized = createNextAccessCredentialFile(
+      this.#file,
       this.#file.credentials
         .filter((credential) => !revoked.includes(credential))
         .map((credential) =>
@@ -594,7 +853,9 @@ class FileRuntimeHostAccessAuthority implements RuntimeHostAccessAuthority {
       finalized,
       revoked.map((credential) => credential.credentialId),
     );
-    return { reconnectRequired: retained.bindClientInstanceOnFinalize === true };
+    return {
+      reconnectRequired: retained.bindClientInstanceOnFinalize === true,
+    };
   }
 
   subscribeRevocations(listener: (credentialId: string) => void): () => void {
@@ -609,6 +870,14 @@ class FileRuntimeHostAccessAuthority implements RuntimeHostAccessAuthority {
     return () => this.#grantRevocationListeners.delete(listener);
   }
 
+  subscribeApprovedTurnAccessRequests(
+    listener: (request: SessionTurnAccessRequest) => void,
+  ): () => void {
+    if (this.#closed) return () => undefined;
+    this.#approvedTurnAccessRequestListeners.add(listener);
+    return () => this.#approvedTurnAccessRequestListeners.delete(listener);
+  }
+
   close(): Promise<void> {
     if (!this.#closed) {
       this.#closed = true;
@@ -616,6 +885,7 @@ class FileRuntimeHostAccessAuthority implements RuntimeHostAccessAuthority {
       this.#expiryTimer = undefined;
       this.#revocationListeners.clear();
       this.#grantRevocationListeners.clear();
+      this.#approvedTurnAccessRequestListeners.clear();
     }
     return this.#mutation;
   }
@@ -636,6 +906,7 @@ class FileRuntimeHostAccessAuthority implements RuntimeHostAccessAuthority {
     file: AccessCredentialFile,
     revokedCredentialIds: readonly string[] = [],
     revokedGrants: readonly SessionCollaborationGrant[] = [],
+    approvedTurnAccessRequests: readonly SessionTurnAccessRequest[] = [],
   ): Promise<void> {
     let outcomeUnknown: RuntimeHostAccessCommitOutcomeUnknownError | undefined;
     try {
@@ -648,6 +919,11 @@ class FileRuntimeHostAccessAuthority implements RuntimeHostAccessAuthority {
     this.#schedulePendingExpiry();
     for (const credentialId of revokedCredentialIds) this.#publishRevocation(credentialId);
     for (const grant of revokedGrants) this.#publishGrantRevocation(grant);
+    if (!outcomeUnknown) {
+      for (const request of approvedTurnAccessRequests) {
+        for (const listener of this.#approvedTurnAccessRequestListeners) listener(request);
+      }
+    }
     if (outcomeUnknown) throw outcomeUnknown;
   }
 
@@ -669,7 +945,8 @@ class FileRuntimeHostAccessAuthority implements RuntimeHostAccessAuthority {
       expiredGuestPrincipals.has(grant.principalId),
     );
     await this.#commit(
-      createAccessCredentialFile(
+      createNextAccessCredentialFile(
+        this.#file,
         this.#file.credentials.filter((credential) => !expired.includes(credential)),
         this.#file.sessionGrants.filter((grant) => !expiredGrants.includes(grant)),
       ),
@@ -726,6 +1003,39 @@ class FileRuntimeHostAccessAuthority implements RuntimeHostAccessAuthority {
   }
 }
 
+function isActiveTurnAccessRequest(request: SessionTurnAccessRequest): boolean {
+  return (
+    request.state.kind === 'pending' ||
+    (request.state.kind === 'approved' && request.state.admission === 'pending')
+  );
+}
+
+function replaceTurnAccessRequest(
+  requests: readonly SessionTurnAccessRequest[],
+  current: SessionTurnAccessRequest,
+  replacement: SessionTurnAccessRequest,
+): readonly SessionTurnAccessRequest[] {
+  return [...requests.filter((request) => request !== current), replacement];
+}
+
+function removePendingTurnAccessRequests(
+  requests: readonly SessionTurnAccessRequest[],
+  matches: (request: SessionTurnAccessRequest) => boolean,
+): readonly SessionTurnAccessRequest[] {
+  return requests.filter((request) => request.state.kind !== 'pending' || !matches(request));
+}
+
+function retirePrincipalTurnAccessRequests(
+  requests: readonly SessionTurnAccessRequest[],
+  principalId: string,
+): readonly SessionTurnAccessRequest[] {
+  return requests.filter(
+    (request) =>
+      request.principalId !== principalId ||
+      (request.state.kind === 'approved' && request.state.admission === 'pending'),
+  );
+}
+
 function activatePendingCredential(
   credential: StoredAccessCredential,
   clientInstanceId: string,
@@ -767,7 +1077,10 @@ export async function issueAccessCredential(
     return { ok: true, result: await authority.issue(input) };
   } catch (error) {
     if (error instanceof RuntimeHostAccessInputError) {
-      return { ok: false, error: { code: 'invalid_request', message: error.message } };
+      return {
+        ok: false,
+        error: { code: 'invalid_request', message: error.message },
+      };
     }
     return accessPersistenceFailure(
       error,
@@ -786,7 +1099,10 @@ export async function replaceAccessCredential(
     return { ok: true, result: await authority.replace(input) };
   } catch (error) {
     if (error instanceof RuntimeHostAccessInputError) {
-      return { ok: false, error: { code: 'invalid_request', message: error.message } };
+      return {
+        ok: false,
+        error: { code: 'invalid_request', message: error.message },
+      };
     }
     return accessPersistenceFailure(
       error,
@@ -805,7 +1121,10 @@ export async function prepareAccessCredential(
     return { ok: true, result: await authority.prepare(input) };
   } catch (error) {
     if (error instanceof RuntimeHostAccessInputError) {
-      return { ok: false, error: { code: 'invalid_request', message: error.message } };
+      return {
+        ok: false,
+        error: { code: 'invalid_request', message: error.message },
+      };
     }
     return accessPersistenceFailure(
       error,
@@ -824,7 +1143,10 @@ export async function prepareAccessCredentialRotation(
     return { ok: true, result: await authority.prepareRotation(input) };
   } catch (error) {
     if (error instanceof RuntimeHostAccessInputError) {
-      return { ok: false, error: { code: 'invalid_request', message: error.message } };
+      return {
+        ok: false,
+        error: { code: 'invalid_request', message: error.message },
+      };
     }
     return accessPersistenceFailure(
       error,
@@ -843,7 +1165,10 @@ export async function revokeAccessCredential(
     return { ok: true, result: await authority.revoke(input) };
   } catch (error) {
     if (error instanceof RuntimeHostAccessInputError) {
-      return { ok: false, error: { code: 'invalid_request', message: error.message } };
+      return {
+        ok: false,
+        error: { code: 'invalid_request', message: error.message },
+      };
     }
     return accessPersistenceFailure(
       error,
@@ -878,7 +1203,10 @@ export async function revokeAccessCredentialRotation(
     return { ok: true, result: await authority.revokeRotation(input) };
   } catch (error) {
     if (error instanceof RuntimeHostAccessInputError) {
-      return { ok: false, error: { code: 'invalid_request', message: error.message } };
+      return {
+        ok: false,
+        error: { code: 'invalid_request', message: error.message },
+      };
     }
     return accessPersistenceFailure(
       error,
@@ -897,20 +1225,32 @@ export async function finalizeAccessCredential(
   if (!credentialId) {
     return {
       ok: false,
-      error: { code: 'invalid_request', message: 'A remote access credential is required' },
+      error: {
+        code: 'invalid_request',
+        message: 'A remote access credential is required',
+      },
     };
   }
   if (!clientInstanceId) {
     return {
       ok: false,
-      error: { code: 'invalid_request', message: 'A Client identity is required' },
+      error: {
+        code: 'invalid_request',
+        message: 'A Client identity is required',
+      },
     };
   }
   try {
-    return { ok: true, result: await authority.finalize(credentialId, clientInstanceId) };
+    return {
+      ok: true,
+      result: await authority.finalize(credentialId, clientInstanceId),
+    };
   } catch (error) {
     if (error instanceof RuntimeHostAccessInputError) {
-      return { ok: false, error: { code: 'invalid_request', message: error.message } };
+      return {
+        ok: false,
+        error: { code: 'invalid_request', message: error.message },
+      };
     }
     return accessPersistenceFailure(
       error,
@@ -927,10 +1267,16 @@ export async function prepareCollaborationInvitation(
 ): Promise<OperationOutcome<'collaboration.invitation.prepare'>> {
   if (!authority) return collaborationUnavailable('collaboration.invitation.prepare');
   try {
-    return { ok: true, result: await authority.prepareCollaborationInvitation(rootId, input) };
+    return {
+      ok: true,
+      result: await authority.prepareCollaborationInvitation(rootId, input),
+    };
   } catch (error) {
     if (error instanceof RuntimeHostAccessInputError) {
-      return { ok: false, error: { code: 'invalid_request', message: error.message } };
+      return {
+        ok: false,
+        error: { code: 'invalid_request', message: error.message },
+      };
     }
     return accessPersistenceFailure(
       error,
@@ -940,13 +1286,109 @@ export async function prepareCollaborationInvitation(
   }
 }
 
+export async function createCollaborationTurnRequest(
+  authority: RuntimeHostAccessAuthority | undefined,
+  principalId: string,
+  input: CollaborationTurnRequestCreateInput,
+): Promise<OperationOutcome<'collaboration.turn-request.create'>> {
+  if (!authority) return collaborationUnavailable('collaboration.turn-request.create');
+  try {
+    return {
+      ok: true,
+      result: await authority.createTurnAccessRequest(principalId, input),
+    };
+  } catch (error) {
+    if (error instanceof RuntimeHostAccessInputError) {
+      return {
+        ok: false,
+        error: { code: 'invalid_request', message: error.message },
+      };
+    }
+    return accessPersistenceFailure(
+      error,
+      'Turn access request outcome is unknown',
+      'Turn access request could not be created',
+    );
+  }
+}
+
+export function queryCollaborationTurnRequests(
+  authority: RuntimeHostAccessAuthority | undefined,
+  principal: {
+    readonly principalId: string;
+    readonly principalKind: RuntimeHostConnectionAuthority['principalKind'] | undefined;
+  },
+  input: CollaborationTurnRequestQueryInput,
+): OperationOutcome<'collaboration.turn-request.query'> {
+  return authority && principal.principalKind
+    ? {
+        ok: true,
+        result: authority.queryTurnAccessRequests(
+          {
+            principalId: principal.principalId,
+            principalKind: principal.principalKind,
+          },
+          input,
+        ),
+      }
+    : collaborationUnavailable('collaboration.turn-request.query');
+}
+
+export async function acknowledgeCollaborationTurnRequest(
+  authority: RuntimeHostAccessAuthority | undefined,
+  principalId: string,
+  input: CollaborationTurnRequestAcknowledgeInput,
+): Promise<OperationOutcome<'collaboration.turn-request.acknowledge'>> {
+  if (!authority) return collaborationUnavailable('collaboration.turn-request.acknowledge');
+  try {
+    return {
+      ok: true,
+      result: await authority.acknowledgeTurnAccessRequest(principalId, input),
+    };
+  } catch (error) {
+    return accessPersistenceFailure(
+      error,
+      'Turn access acknowledgement outcome is unknown',
+      'Turn access request could not be acknowledged',
+    );
+  }
+}
+
+export async function decideCollaborationTurnRequest(
+  authority: RuntimeHostAccessAuthority | undefined,
+  principalId: string,
+  input: CollaborationTurnRequestDecideInput,
+): Promise<OperationOutcome<'collaboration.turn-request.decide'>> {
+  if (!authority) return collaborationUnavailable('collaboration.turn-request.decide');
+  try {
+    return {
+      ok: true,
+      result: await authority.decideTurnAccessRequest(principalId, input),
+    };
+  } catch (error) {
+    if (error instanceof RuntimeHostAccessInputError) {
+      return {
+        ok: false,
+        error: { code: 'invalid_request', message: error.message },
+      };
+    }
+    return accessPersistenceFailure(
+      error,
+      'Turn access decision outcome is unknown',
+      'Turn access request could not be decided',
+    );
+  }
+}
 export async function revokeCollaborationGrant(
   authority: RuntimeHostAccessAuthority | undefined,
   input: CollaborationGrantRevokeInput,
 ): Promise<OperationOutcome<'collaboration.grant.revoke'>> {
   if (!authority) return collaborationUnavailable('collaboration.grant.revoke');
   try {
-    return { ok: true, result: await authority.revokeCollaborationGrant(input) };
+    return {
+      ok: true,
+      result: await authority.revokeCollaborationGrant(input),
+    };
   } catch (error) {
     return accessPersistenceFailure(
       error,
@@ -962,7 +1404,10 @@ export async function revokeCollaborationPrincipal(
 ): Promise<OperationOutcome<'collaboration.principal.revoke'>> {
   if (!authority) return collaborationUnavailable('collaboration.principal.revoke');
   try {
-    return { ok: true, result: await authority.revokeCollaborationPrincipal(principalId) };
+    return {
+      ok: true,
+      result: await authority.revokeCollaborationPrincipal(principalId),
+    };
   } catch (error) {
     return accessPersistenceFailure(
       error,
@@ -976,7 +1421,11 @@ function collaborationUnavailable<
   K extends
     | 'collaboration.invitation.prepare'
     | 'collaboration.grant.revoke'
-    | 'collaboration.principal.revoke',
+    | 'collaboration.principal.revoke'
+    | 'collaboration.turn-request.create'
+    | 'collaboration.turn-request.acknowledge'
+    | 'collaboration.turn-request.decide'
+    | 'collaboration.turn-request.query',
 >(operation: K): OperationOutcome<K> {
   return {
     ok: false,
diff --git a/packages/runtime-host/src/server/access-credential-store.ts b/packages/runtime-host/src/server/access-credential-store.ts
index e4fe1b819..292cede83 100644
--- a/packages/runtime-host/src/server/access-credential-store.ts
+++ b/packages/runtime-host/src/server/access-credential-store.ts
@@ -25,10 +25,12 @@ import {
   HOST_OPERATION_SPECS,
   operationAllowsRemoteOwner,
   type SessionCollaborationGrant,
+  decodeSessionTurnAccessRequest,
+  type SessionTurnAccessRequest,
   type OperationKey,
 } from '../protocol/index.js';
 
-const ACCESS_FILE_SCHEMA_VERSION = 2;
+const ACCESS_FILE_SCHEMA_VERSION = 3;
 const ACCESS_FILE_MAX_BYTES = 512 * 1024;
 const LEGACY_TRANSCRIPT_QUERY_GRANT = 'session.transcript.query';
 const TRANSCRIPT_QUERY_REPLACEMENT_GRANTS = [
@@ -58,6 +60,9 @@ export const ACCESS_FILE_NAME = 'runtime-host-access.json';
 export const SESSION_GUEST_OPERATION_GRANTS = Object.freeze([
   'host.status',
   'artifact.query',
+  'collaboration.turn-request.create',
+  'collaboration.turn-request.acknowledge',
+  'collaboration.turn-request.query',
   'runtime.resource.query',
   'session.shared.query',
   'subscription.open',
@@ -86,6 +91,7 @@ export interface AccessCredentialFile {
   readonly schemaVersion: typeof ACCESS_FILE_SCHEMA_VERSION;
   readonly credentials: readonly StoredAccessCredential[];
   readonly sessionGrants: readonly SessionCollaborationGrant[];
+  readonly turnAccessRequests: readonly SessionTurnAccessRequest[];
 }
 
 export class RuntimeHostAccessInputError extends Error {
@@ -112,8 +118,14 @@ export class RuntimeHostAccessCommitOutcomeUnknownError extends Error {
 export function createAccessCredentialFile(
   credentials: readonly StoredAccessCredential[],
   sessionGrants: readonly SessionCollaborationGrant[] = [],
+  turnAccessRequests: readonly SessionTurnAccessRequest[] = [],
 ): AccessCredentialFile {
-  return { schemaVersion: ACCESS_FILE_SCHEMA_VERSION, credentials, sessionGrants };
+  return {
+    schemaVersion: ACCESS_FILE_SCHEMA_VERSION,
+    credentials,
+    sessionGrants,
+    turnAccessRequests,
+  };
 }
 
 export function issuedAccessGrants(grants: readonly OperationKey[]): readonly OperationKey[] {
@@ -132,10 +144,28 @@ export function assertAccessCredentialFileCapacity(file: AccessCredentialFile):
           },
     ),
     file.sessionGrants,
+    file.turnAccessRequests.map(reserveTurnAccessRequestCompletionCapacity),
   );
   serializeAccessCredentialFile(fullyRevoked);
 }
 
+function reserveTurnAccessRequestCompletionCapacity(
+  request: SessionTurnAccessRequest,
+): SessionTurnAccessRequest {
+  if (request.state.kind !== 'pending' && request.state.kind !== 'approved') return request;
+  if (request.state.kind === 'approved' && request.state.admission !== 'pending') return request;
+  return {
+    ...request,
+    state: {
+      kind: 'approved',
+      decidedAt:
+        request.state.kind === 'approved' ? request.state.decidedAt : '9999-12-31T23:59:59.999Z',
+      decidedBy: request.state.kind === 'approved' ? request.state.decidedBy : 'x'.repeat(128),
+      admission: 'failed',
+    },
+  };
+}
+
 export async function readAccessCredentialFile(path: string): Promise<AccessCredentialFile> {
   let handle: FileHandle;
   try {
@@ -193,7 +223,10 @@ function serializeAccessCredentialFile(file: AccessCredentialFile): string {
 }
 
 function decodeAccessFile(value: unknown): AccessCredentialFile {
-  if (!isRecord(value) || (value.schemaVersion !== 1 && value.schemaVersion !== 2)) {
+  if (
+    !isRecord(value) ||
+    (value.schemaVersion !== 1 && value.schemaVersion !== 2 && value.schemaVersion !== 3)
+  ) {
     throw new Error('Unsupported Runtime Host access file');
   }
   if (!Array.isArray(value.credentials)) throw new Error('Invalid Runtime Host access file');
@@ -228,7 +261,21 @@ function decodeAccessFile(value: unknown): AccessCredentialFile {
     }
     sessionByGuest.set(grant.principalId, grant.sessionId);
   }
-  return createAccessCredentialFile(credentials, sessionGrants);
+  const turnAccessRequests =
+    value.schemaVersion < 3
+      ? []
+      : Array.isArray(value.turnAccessRequests)
+        ? value.turnAccessRequests.map(decodeSessionTurnAccessRequest)
+        : (() => {
+            throw new Error('Invalid Runtime Host Turn access requests');
+          })();
+  if (
+    new Set(turnAccessRequests.map((request) => request.requestId)).size !==
+    turnAccessRequests.length
+  ) {
+    throw new Error('Duplicate Runtime Host Turn access request identity');
+  }
+  return createAccessCredentialFile(credentials, sessionGrants, turnAccessRequests);
 }
 
 function decodeStoredCredential(value: unknown): StoredAccessCredential {
diff --git a/packages/runtime-host/src/server/connection-authority.ts b/packages/runtime-host/src/server/connection-authority.ts
index 3b7c9f5b0..8e7f83a1b 100644
--- a/packages/runtime-host/src/server/connection-authority.ts
+++ b/packages/runtime-host/src/server/connection-authority.ts
@@ -81,7 +81,11 @@ export function authorizeRuntimeHostOperation(
   authority: RuntimeHostConnectionAuthority,
   frame: RequestFrame,
 ): boolean {
-  if (authority.principalKind !== 'local_owner' && !operationAllowsRemoteOwner(frame.operation)) {
+  if (
+    (authority.principalKind === 'remote_owner' ||
+      authority.principalKind === 'capability_provider') &&
+    !operationAllowsRemoteOwner(frame.operation)
+  ) {
     return false;
   }
   if (authority.operationGrants !== 'all' && !authority.operationGrants.includes(frame.operation)) {
diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts
index edc2ff841..ac1b5e85f 100644
--- a/packages/runtime-host/src/server/execution-composition.ts
+++ b/packages/runtime-host/src/server/execution-composition.ts
@@ -141,6 +141,7 @@ import {
 } from './host-composition.js';
 import { HostInteractionCoordinator } from './interaction-coordinator.js';
 import { HostInteractiveTurnCoordinator } from './interactive-turn-coordinator.js';
+import { SessionTurnAccessRequestCoordinator } from './session-turn-access-request-coordinator.js';
 import { ensureBootstrapRuntimePolicy } from './bootstrap-runtime-policy.js';
 import { hostedExecutionRunProfile } from './hosted-execution-tool-profile.js';
 import { HostMemoryCoordinator } from './memory-coordinator.js';
@@ -493,7 +494,9 @@ export async function createExecutionRuntimeHostComposition(
     const projects = new HostProjectCatalogCoordinator(
       openedProjectCatalog,
       { publish: () => hostChanges.publishProjectCatalog() },
-      { publish: (sessionId: string) => hostChanges.publishSessionCatalog(sessionId) },
+      {
+        publish: (sessionId: string) => hostChanges.publishSessionCatalog(sessionId),
+      },
       projectMembership,
       context.requestDrain,
       new HostProjectDirectoryAuthority(options.projectDirectoryRoots),
@@ -1177,6 +1180,16 @@ export async function createExecutionRuntimeHostComposition(
       turns: stores.agentRunStore,
       runtime: manager,
     });
+    const turnAccessRequests = context.sessionAccessAuthority
+      ? new SessionTurnAccessRequestCoordinator({
+          authority: context.sessionAccessAuthority,
+          startTurn: interactiveTurns.handlers['turn.start'],
+          hostEpoch: context.hostEpoch,
+          acquireResidency: () => context.acquireResidency('collaboration-turn-request'),
+          requestDrain: context.requestDrain,
+          whenIdle: (sessionId) => coordinator.whenIdle(sessionId),
+        })
+      : undefined;
     const graphExecutions = new HostAgentGraphExecutionCoordinator({
       executions: coordinator,
       runtime: manager,
@@ -1687,10 +1700,12 @@ export async function createExecutionRuntimeHostComposition(
             await messages.recoverPendingAfterHostRestart(
               recoverySessions.map((session) => session.id),
             );
+            await turnAccessRequests?.recover();
             rootRecoveryCompleted = true;
           },
         },
         drain: [
+          () => turnAccessRequests?.beginDrain(),
           () => rootCoordinator?.beginDrain(),
           () => workspaceExecution?.beginDrain(),
           () => runtimeResources?.beginDrain(),
@@ -1709,6 +1724,7 @@ export async function createExecutionRuntimeHostComposition(
           () => sessionEffects?.close(),
           () => messages.close(),
           () => interactions.close(),
+          () => turnAccessRequests?.close(),
           () => continuityCoordinator.close(),
         ],
         releaseConnection: [(connectionId) => runtimeResources?.releaseConnection(connectionId)],
@@ -1877,7 +1893,10 @@ function adaptWorkspaceFilesystemWorker(
     async execute(input) {
       // Read-only operations never participate in CAS; the adapter says so
       // explicitly (#3484) instead of relying on an absent optional field.
-      const result = await worker.execute({ ...input, expectedIdentity: 'unchecked' });
+      const result = await worker.execute({
+        ...input,
+        expectedIdentity: 'unchecked',
+      });
       switch (result.kind) {
         case 'read':
         case 'read_image':
diff --git a/packages/runtime-host/src/server/host-kernel.ts b/packages/runtime-host/src/server/host-kernel.ts
index c0be10e94..4ab9603da 100644
--- a/packages/runtime-host/src/server/host-kernel.ts
+++ b/packages/runtime-host/src/server/host-kernel.ts
@@ -59,8 +59,12 @@ import {
 } from './operation-dispatcher.js';
 import {
   issueAccessCredential,
+  acknowledgeCollaborationTurnRequest,
+  createCollaborationTurnRequest,
+  decideCollaborationTurnRequest,
   finalizeAccessCredential,
   prepareCollaborationInvitation,
+  queryCollaborationTurnRequests,
   prepareAccessCredential,
   prepareAccessCredentialRotation,
   replaceAccessCredential,
@@ -121,7 +125,12 @@ export interface RuntimeHostCompositionContext {
   requestDrain(): void;
   sessionAccessAuthority?: Pick<
     RuntimeHostAccessAuthority,
-    'activeSessionGrant' | 'activeSessionGrantForPrincipal' | 'subscribeGrantRevocations'
+    | 'activeSessionGrant'
+    | 'activeSessionGrantForPrincipal'
+    | 'approvedTurnAccessRequests'
+    | 'completeTurnAccessRequest'
+    | 'subscribeGrantRevocations'
+    | 'subscribeApprovedTurnAccessRequests'
   >;
   waitForResidencies?(): Promise<void>;
   waitForResidenciesExcept?(excludedLabel: string): Promise<void>;
@@ -723,7 +732,10 @@ export class RuntimeHostKernel {
           ),
         'collaboration.access.query': async (input) =>
           this.#options.accessAuthority
-            ? { ok: true, result: this.#options.accessAuthority.queryCollaborationAccess(input) }
+            ? {
+                ok: true,
+                result: this.#options.accessAuthority.queryCollaborationAccess(input),
+              }
             : {
                 ok: false,
                 error: {
@@ -739,6 +751,31 @@ export class RuntimeHostKernel {
           this.#settleAccessCredentialMutation(
             revokeCollaborationPrincipal(this.#options.accessAuthority, input.principalId),
           ),
+        'collaboration.turn-request.create': async (input, context) =>
+          this.#settleAccessCredentialMutation(
+            createCollaborationTurnRequest(this.#options.accessAuthority, context.principal, input),
+          ),
+        'collaboration.turn-request.query': async (input, context) =>
+          queryCollaborationTurnRequests(
+            this.#options.accessAuthority,
+            {
+              principalId: context.principal,
+              principalKind: context.principalKind,
+            },
+            input,
+          ),
+        'collaboration.turn-request.acknowledge': async (input, context) =>
+          this.#settleAccessCredentialMutation(
+            acknowledgeCollaborationTurnRequest(
+              this.#options.accessAuthority,
+              context.principal,
+              input,
+            ),
+          ),
+        'collaboration.turn-request.decide': async (input, context) =>
+          this.#settleAccessCredentialMutation(
+            decideCollaborationTurnRequest(this.#options.accessAuthority, context.principal, input),
+          ),
       },
       createPeerMeshOperationHandlers(this.#options.peerMesh, {
         requestDrain: () => this.#requestDrain(),
diff --git a/packages/runtime-host/src/server/operation-dispatcher.ts b/packages/runtime-host/src/server/operation-dispatcher.ts
index 856afe48b..b752189df 100644
--- a/packages/runtime-host/src/server/operation-dispatcher.ts
+++ b/packages/runtime-host/src/server/operation-dispatcher.ts
@@ -19,6 +19,7 @@
 
 import { truncateUtf8 } from '@maka/core/diagnostic-log';
 import { redactSecrets } from '@maka/core/redaction';
+import type { RootTurnAdmissionAuthorization } from '@maka/storage/execution-stores';
 import {
   HOST_OPERATION_SPECS,
   decodeOperationOutcome,
@@ -45,6 +46,7 @@ export interface ConnectionContext {
   principalKind?: RuntimeHostConnectionAuthority['principalKind'];
   credentialId?: string;
   clientInstanceId?: string;
+  turnAdmissionAuthorization?: RootTurnAdmissionAuthorization;
   acquireResidency(): OperationResidency;
 }
 
@@ -366,6 +368,34 @@ export function createUnavailableAccessAuthorityOperationHandlers(): AccessAutho
         message: 'Runtime Host collaboration authority is unavailable',
       },
     }),
+    'collaboration.turn-request.create': async () => ({
+      ok: false,
+      error: {
+        code: 'operation_unavailable',
+        message: 'Runtime Host collaboration authority is unavailable',
+      },
+    }),
+    'collaboration.turn-request.acknowledge': async () => ({
+      ok: false,
+      error: {
+        code: 'operation_unavailable',
+        message: 'Runtime Host collaboration authority is unavailable',
+      },
+    }),
+    'collaboration.turn-request.query': async () => ({
+      ok: false,
+      error: {
+        code: 'operation_unavailable',
+        message: 'Runtime Host collaboration authority is unavailable',
+      },
+    }),
+    'collaboration.turn-request.decide': async () => ({
+      ok: false,
+      error: {
+        code: 'operation_unavailable',
+        message: 'Runtime Host collaboration authority is unavailable',
+      },
+    }),
   };
 }
 
diff --git a/packages/runtime-host/src/server/root-admission-owner.ts b/packages/runtime-host/src/server/root-admission-owner.ts
index b199fb930..759fa3ee2 100644
--- a/packages/runtime-host/src/server/root-admission-owner.ts
+++ b/packages/runtime-host/src/server/root-admission-owner.ts
@@ -120,6 +120,7 @@ function sameRootAdmission(left: RootTurnAdmission, right: RootTurnAdmission): b
     isDeepStrictEqual(left.execution, right.execution) &&
     isDeepStrictEqual(left.turnOrchestration, right.turnOrchestration) &&
     isDeepStrictEqual(left.skillInvocation, right.skillInvocation) &&
+    isDeepStrictEqual(left.authorization, right.authorization) &&
     left.previousRootTurnId === right.previousRootTurnId &&
     (left.normalizedInput === null || right.normalizedInput === null
       ? left.normalizedInput === right.normalizedInput
@@ -154,6 +155,9 @@ function snapshotAdmission(admission: RootTurnAdmission): RootTurnAdmission {
     ...(admission.turnOrchestration
       ? { turnOrchestration: Object.freeze({ ...admission.turnOrchestration }) }
       : {}),
+    ...(admission.authorization
+      ? { authorization: Object.freeze({ ...admission.authorization }) }
+      : {}),
     normalizedInput:
       admission.normalizedInput === null ? null : snapshotMessageContent(admission.normalizedInput),
     sourceMessages: Object.freeze(sourceMessages),
diff --git a/packages/runtime-host/src/server/root-turn-coordinator.ts b/packages/runtime-host/src/server/root-turn-coordinator.ts
index 2406f4ee1..86d7ceed9 100644
--- a/packages/runtime-host/src/server/root-turn-coordinator.ts
+++ b/packages/runtime-host/src/server/root-turn-coordinator.ts
@@ -1438,7 +1438,14 @@ export class RootTurnCoordinator implements HostedExecutionAuthority {
                 ? request.content
                 : requireHostedExecutionMessageContent(existing);
           }
-          if (!rootMessageAdmissionMatches(existing, request, content)) {
+          if (
+            !rootMessageAdmissionMatches(
+              existing,
+              request,
+              content,
+              context.turnAdmissionAuthorization,
+            )
+          ) {
             return completedStart(
               operationConflict('Turn identity was already admitted with a different payload'),
             );
@@ -1543,6 +1550,9 @@ export class RootTurnCoordinator implements HostedExecutionAuthority {
           normalizedInput: canonicalContent.content,
           ...(request.turnOrchestration ? { turnOrchestration: request.turnOrchestration } : {}),
           ...(prepared.skillInvocation ? { skillInvocation: prepared.skillInvocation } : {}),
+          ...(context.turnAdmissionAuthorization
+            ? { authorization: context.turnAdmissionAuthorization }
+            : {}),
           sourceMessages: [],
           admittedAt: Date.now(),
         });
@@ -1551,7 +1561,14 @@ export class RootTurnCoordinator implements HostedExecutionAuthority {
             operationConflict('Turn identity belongs to a different execution kind'),
           );
         }
-        if (!rootMessageAdmissionMatches(admitted.admission, request, canonicalContent.content)) {
+        if (
+          !rootMessageAdmissionMatches(
+            admitted.admission,
+            request,
+            canonicalContent.content,
+            context.turnAdmissionAuthorization,
+          )
+        ) {
           return completedStart(
             operationConflict('Turn identity was already admitted with a different payload'),
           );
@@ -2726,6 +2743,7 @@ function rootMessageAdmissionMatches(
   admission: RootTurnAdmission,
   request: RootMessageStartRequest,
   content: MessageContent,
+  authorization: ConnectionContext['turnAdmissionAuthorization'],
 ): boolean {
   return (
     isDeepStrictEqual(admission.execution, request.execution) &&
@@ -2733,6 +2751,7 @@ function rootMessageAdmissionMatches(
       ? true
       : messageContentsEqual(requireHostedExecutionMessageContent(admission), content)) &&
     isDeepStrictEqual(admission.turnOrchestration, request.turnOrchestration) &&
+    isDeepStrictEqual(admission.authorization, authorization) &&
     admission.sourceMessages.length === 0
   );
 }
diff --git a/packages/runtime-host/src/server/session-turn-access-request-coordinator.ts b/packages/runtime-host/src/server/session-turn-access-request-coordinator.ts
new file mode 100644
index 000000000..6cd33f19b
--- /dev/null
+++ b/packages/runtime-host/src/server/session-turn-access-request-coordinator.ts
@@ -0,0 +1,143 @@
+/*
+ * 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 { SessionTurnAccessRequest } from '../protocol/index.js';
+import type { RuntimeHostAccessAuthority } from './access-authority.js';
+import type {
+  ConnectionContext,
+  OperationHandler,
+  OperationResidency,
+} from './operation-dispatcher.js';
+
+type TurnAccessRequestAuthority = Pick<
+  RuntimeHostAccessAuthority,
+  'approvedTurnAccessRequests' | 'completeTurnAccessRequest' | 'subscribeApprovedTurnAccessRequests'
+>;
+
+export class SessionTurnAccessRequestCoordinator {
+  readonly #authority: TurnAccessRequestAuthority;
+  readonly #startTurn: OperationHandler<'turn.start'>;
+  readonly #acquireResidency: () => OperationResidency;
+  readonly #requestDrain: () => void;
+  readonly #whenIdle: (sessionId: string) => Promise<void> | undefined;
+  readonly #hostEpoch: string;
+  readonly #tasks = new Map<string, Promise<void>>();
+  readonly #unsubscribe: () => void;
+  #draining = false;
+
+  constructor(input: {
+    readonly authority: TurnAccessRequestAuthority;
+    readonly startTurn: OperationHandler<'turn.start'>;
+    readonly acquireResidency: () => OperationResidency;
+    readonly requestDrain: () => void;
+    readonly whenIdle: (sessionId: string) => Promise<void> | undefined;
+    readonly hostEpoch: string;
+  }) {
+    this.#authority = input.authority;
+    this.#startTurn = input.startTurn;
+    this.#acquireResidency = input.acquireResidency;
+    this.#requestDrain = input.requestDrain;
+    this.#whenIdle = input.whenIdle;
+    this.#hostEpoch = input.hostEpoch;
+    this.#unsubscribe = input.authority.subscribeApprovedTurnAccessRequests((request) => {
+      this.#schedule(request);
+    });
+  }
+
+  recover(): void {
+    for (const request of this.#authority.approvedTurnAccessRequests()) {
+      this.#schedule(request);
+    }
+  }
+
+  beginDrain(): void {
+    if (this.#draining) return;
+    this.#draining = true;
+    this.#unsubscribe();
+  }
+
+  async close(): Promise<void> {
+    this.beginDrain();
+    await this.#settled();
+  }
+
+  #schedule(request: SessionTurnAccessRequest): void {
+    if (this.#draining || this.#tasks.has(request.requestId)) return;
+    const task = this.#admit(request).finally(() => {
+      this.#tasks.delete(request.requestId);
+    });
+    this.#tasks.set(request.requestId, task);
+  }
+
+  async #admit(request: SessionTurnAccessRequest): Promise<void> {
+    if (request.state.kind !== 'approved' || request.state.admission !== 'pending') return;
+    const residency = this.#acquireResidency();
+    const context: ConnectionContext = {
+      hostEpoch: this.#hostEpoch,
+      connectionId: `collaboration:${request.requestId}`,
+      principal: request.principalId,
+      turnAdmissionAuthorization: {
+        kind: 'session_turn_access_request',
+        requestId: request.requestId,
+        principalId: request.principalId,
+        grantId: request.grantId,
+        approvedAt: Date.parse(request.state.decidedAt),
+        approvedBy: request.state.decidedBy,
+      },
+      acquireResidency: this.#acquireResidency,
+    };
+    try {
+      let outcome: Awaited<ReturnType<OperationHandler<'turn.start'>>>;
+      for (;;) {
+        try {
+          outcome = await this.#startTurn(request.intent, context);
+        } catch {
+          this.#requestDrain();
+          return;
+        }
+        if (outcome.ok || outcome.error.code !== 'session_busy') break;
+        const whenIdle = this.#whenIdle(request.intent.sessionId);
+        if (whenIdle) {
+          try {
+            await whenIdle;
+          } catch {
+            this.#requestDrain();
+            return;
+          }
+        }
+        if (this.#draining) return;
+      }
+      if (this.#draining && !outcome.ok) return;
+      try {
+        await this.#authority.completeTurnAccessRequest(
+          request.requestId,
+          outcome.ok ? outcome.result.kind : 'failed',
+        );
+      } catch {
+        this.#requestDrain();
+      }
+    } finally {
+      residency.release();
+    }
+  }
+
+  #settled(): Promise<void> {
+    return Promise.all(this.#tasks.values()).then(() => undefined);
+  }
+}
diff --git a/packages/storage/src/agent-run-store.ts b/packages/storage/src/agent-run-store.ts
index 6feb75144..27481f3fe 100644
--- a/packages/storage/src/agent-run-store.ts
+++ b/packages/storage/src/agent-run-store.ts
@@ -123,10 +123,20 @@ export interface RootTurnAdmission {
   normalizedInput: MessageContent | null;
   turnOrchestration?: TurnOrchestration;
   skillInvocation?: SkillInvocationResult;
+  authorization?: RootTurnAdmissionAuthorization;
   sourceMessages: readonly RootTurnSourceMessage[];
   admittedAt: number;
 }
 
+export interface RootTurnAdmissionAuthorization {
+  readonly kind: 'session_turn_access_request';
+  readonly requestId: string;
+  readonly principalId: string;
+  readonly grantId: string;
+  readonly approvedAt: number;
+  readonly approvedBy: string;
+}
+
 export interface RootTurnStartRejection {
   schemaVersion: 1;
   sessionId: string;
@@ -146,6 +156,7 @@ export interface AdmitRootTurnInput {
   normalizedInput: MessageContent | null;
   turnOrchestration?: TurnOrchestration;
   skillInvocation?: SkillInvocationResult;
+  authorization?: RootTurnAdmissionAuthorization;
   sourceMessages: readonly RootTurnSourceMessage[];
   admittedAt: number;
 }
@@ -1213,6 +1224,7 @@ function normalizeAdmitRootTurnInput(input: AdmitRootTurnInput): RootTurnAdmissi
     input.skillInvocation === undefined
       ? undefined
       : decodeSkillInvocationResult(input.skillInvocation);
+  const authorization = normalizeRootTurnAdmissionAuthorization(input.authorization);
   const execution = normalizeRootExecutionDescriptor(input.execution);
   if (execution.kind === 'legacy_automation') {
     throw new Error('New root admission cannot use removed Automation authority');
@@ -1228,6 +1240,7 @@ function normalizeAdmitRootTurnInput(input: AdmitRootTurnInput): RootTurnAdmissi
     normalizedInput,
     ...(turnOrchestration ? { turnOrchestration } : {}),
     ...(skillInvocation ? { skillInvocation } : {}),
+    ...(authorization ? { authorization } : {}),
     sourceMessages,
     admittedAt: input.admittedAt,
   };
@@ -1442,6 +1455,7 @@ function normalizeRootTurnAdmission(
     record.skillInvocation === undefined
       ? undefined
       : decodeSkillInvocationResult(record.skillInvocation);
+  const authorization = normalizeRootTurnAdmissionAuthorization(record.authorization);
   const admission: RootTurnAdmission = {
     schemaVersion: ROOT_TURN_ADMISSION_SCHEMA_VERSION,
     sessionId,
@@ -1453,6 +1467,7 @@ function normalizeRootTurnAdmission(
     normalizedInput,
     ...(turnOrchestration ? { turnOrchestration } : {}),
     ...(skillInvocation ? { skillInvocation } : {}),
+    ...(authorization ? { authorization } : {}),
     sourceMessages,
     admittedAt: record.admittedAt as number,
   };
@@ -1693,6 +1708,7 @@ function rootTurnAdmissionPayloadsEqual(
     isDeepStrictEqual(left.execution, right.execution) &&
     isDeepStrictEqual(left.turnOrchestration, right.turnOrchestration) &&
     isDeepStrictEqual(left.skillInvocation, right.skillInvocation) &&
+    isDeepStrictEqual(left.authorization, right.authorization) &&
     (left.normalizedInput === null || right.normalizedInput === null
       ? left.normalizedInput === right.normalizedInput
       : messageContentsEqual(left.normalizedInput, right.normalizedInput)) &&
@@ -1763,6 +1779,11 @@ function assertRootTurnAdmissionContract(admission: RootTurnAdmission): void {
       'Invalid root turn admission contract: Skill invocation requires external message execution',
     );
   }
+  if (admission.authorization && execution.kind !== 'external_message') {
+    throw new Error(
+      'Invalid root turn admission contract: authorization proof requires external message execution',
+    );
+  }
   if (execution.kind === 'claimed_agent_graph_intent') {
     if (
       execution.claim.targetSessionId !== admission.sessionId ||
@@ -1824,6 +1845,7 @@ function deepFreezeRootTurnAdmission(admission: RootTurnAdmission): RootTurnAdmi
   Object.freeze(admission.execution);
   if (admission.turnOrchestration) Object.freeze(admission.turnOrchestration);
   if (admission.skillInvocation) Object.freeze(admission.skillInvocation);
+  if (admission.authorization) Object.freeze(admission.authorization);
   if (admission.normalizedInput) deepFreezeRootTurnMessageContent(admission.normalizedInput);
   for (const sourceMessage of admission.sourceMessages) {
     deepFreezeRootTurnMessageContent(sourceMessage.content);
@@ -1846,6 +1868,44 @@ function normalizeTurnOrchestration(value: unknown): TurnOrchestration | undefin
   return Object.freeze({ mode: value.mode, source: value.source });
 }
 
+function normalizeRootTurnAdmissionAuthorization(
+  value: unknown,
+): RootTurnAdmissionAuthorization | undefined {
+  if (value === undefined) return undefined;
+  if (
+    !isPlainRecord(value) ||
+    !hasExactKeys(value, [
+      'kind',
+      'requestId',
+      'principalId',
+      'grantId',
+      'approvedAt',
+      'approvedBy',
+    ]) ||
+    value.kind !== 'session_turn_access_request' ||
+    typeof value.requestId !== 'string' ||
+    !isSafeId(value.requestId) ||
+    typeof value.principalId !== 'string' ||
+    !isGraphControlIdentity(value.principalId) ||
+    typeof value.grantId !== 'string' ||
+    !isSafeId(value.grantId) ||
+    !Number.isSafeInteger(value.approvedAt) ||
+    (value.approvedAt as number) < 0 ||
+    typeof value.approvedBy !== 'string' ||
+    !isGraphControlIdentity(value.approvedBy)
+  ) {
+    throw new Error('Invalid root turn admission authorization');
+  }
+  return Object.freeze({
+    kind: value.kind,
+    requestId: value.requestId,
+    principalId: value.principalId,
+    grantId: value.grantId,
+    approvedAt: value.approvedAt as number,
+    approvedBy: value.approvedBy,
+  });
+}
+
 function hasRootTurnAdmissionKeys(record: Record<string, unknown>): boolean {
   const keys = [
     'schemaVersion',
@@ -1859,7 +1919,7 @@ function hasRootTurnAdmissionKeys(record: Record<string, unknown>): boolean {
     'sourceMessages',
     'admittedAt',
   ];
-  const optionalKeys = ['turnOrchestration', 'skillInvocation'].filter((key) =>
+  const optionalKeys = ['turnOrchestration', 'skillInvocation', 'authorization'].filter((key) =>
     Object.hasOwn(record, key),
   );
   return hasExactKeys(record, [...keys, ...optionalKeys]);
diff --git a/packages/storage/src/execution-stores.ts b/packages/storage/src/execution-stores.ts
index f50ffee65..58b86e134 100644
--- a/packages/storage/src/execution-stores.ts
+++ b/packages/storage/src/execution-stores.ts
@@ -40,6 +40,7 @@ import {
   type DurableRuntimeEventStore,
   type EvidenceReadBudget,
   type RootTurnAdmission,
+  type RootTurnAdmissionAuthorization,
   type RootTurnSourceMessageReceipt,
 } from './agent-run-store.js';
 import {
@@ -102,6 +103,7 @@ export type {
   EvidenceReadBudget,
   ImmutableSteeringMessageProof,
   RootTurnAdmission,
+  RootTurnAdmissionAuthorization,
   RootTurnAdmissionStore,
   RootTurnStartRejectionStore,
   RootTurnSourceMessage,


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