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

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

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

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

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

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

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


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