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:16 UTC

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

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