From eeb0a1cb15573e3ba9f3c833442992c0486b9462 Mon Sep 17 00:00:00 2001 From: devartifex Date: Tue, 9 Jun 2026 23:20:29 +0200 Subject: [PATCH 01/14] feat(sdk): upgrade @github/copilot-sdk to 1.0.0 stable - getMessages() -> getEvents() - configDir -> configDirectory on session/resume configs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- package-lock.json | 80 +++++++++---------- package.json | 2 +- src/lib/server/copilot/session.test.ts | 4 +- src/lib/server/copilot/session.ts | 4 +- .../ws/message-handlers/resume-session.ts | 2 +- .../ws/message-handlers/session-management.ts | 2 +- 6 files changed, 47 insertions(+), 47 deletions(-) diff --git a/package-lock.json b/package-lock.json index c6a9bce..2504622 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "hasInstallScript": true, "license": "MIT", "dependencies": { - "@github/copilot-sdk": "1.0.0-beta.8", + "@github/copilot-sdk": "^1.0.0", "@sveltejs/adapter-node": "^5.5.4", "@sveltejs/kit": "^2.61.1", "dompurify": "^3.4.7", @@ -368,9 +368,9 @@ } }, "node_modules/@github/copilot": { - "version": "1.0.55-5", - "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.55-5.tgz", - "integrity": "sha512-n6Vr876Iz41PW8pSpOa7SbrNCqaV+6HDLNf/n8V4gIwwlOlIz7Jb00r/fboXZFIT+0dyAGGLoGgd7xUujVL/Xw==", + "version": "1.0.60", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.60.tgz", + "integrity": "sha512-+GjW+GJNo55nwJwt48o9szWcyhuY0u682cBKQI1ay9jVBX8DCCXC6HB6Tyv5/MaM4N7CxTiEgp48aVMkye8K+g==", "license": "SEE LICENSE IN LICENSE.md", "dependencies": { "detect-libc": "^2.1.2" @@ -379,20 +379,20 @@ "copilot": "npm-loader.js" }, "optionalDependencies": { - "@github/copilot-darwin-arm64": "1.0.55-5", - "@github/copilot-darwin-x64": "1.0.55-5", - "@github/copilot-linux-arm64": "1.0.55-5", - "@github/copilot-linux-x64": "1.0.55-5", - "@github/copilot-linuxmusl-arm64": "1.0.55-5", - "@github/copilot-linuxmusl-x64": "1.0.55-5", - "@github/copilot-win32-arm64": "1.0.55-5", - "@github/copilot-win32-x64": "1.0.55-5" + "@github/copilot-darwin-arm64": "1.0.60", + "@github/copilot-darwin-x64": "1.0.60", + "@github/copilot-linux-arm64": "1.0.60", + "@github/copilot-linux-x64": "1.0.60", + "@github/copilot-linuxmusl-arm64": "1.0.60", + "@github/copilot-linuxmusl-x64": "1.0.60", + "@github/copilot-win32-arm64": "1.0.60", + "@github/copilot-win32-x64": "1.0.60" } }, "node_modules/@github/copilot-darwin-arm64": { - "version": "1.0.55-5", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.55-5.tgz", - "integrity": "sha512-Mult62GJVnxR3MOP2QNiVU5RRGXPJ+7BpjEMIvkoaMuWX6J7F4bz7N+HUXVHJUiGUp3hnL3M16kjkewWfNdoNg==", + "version": "1.0.60", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.60.tgz", + "integrity": "sha512-TErNaVxsv+uB3bdHwdoKorCd1rhiRh7HkX48vnS7jwqa8EtGgAkzNrHKC7mruL2rnYOOsNIdPfhzQk+2Y6PSxQ==", "cpu": [ "arm64" ], @@ -406,9 +406,9 @@ } }, "node_modules/@github/copilot-darwin-x64": { - "version": "1.0.55-5", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.55-5.tgz", - "integrity": "sha512-IfY3WhNvHwXHldI2ARsiAYuPlKWlI07Fo1ALq+SViHhn0Zfp2yIr9laJRofyj0G1EbyUxkbNlqQm7UrXhkEVeg==", + "version": "1.0.60", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.60.tgz", + "integrity": "sha512-PthhcR6PqbQlT04xQKTElpPSJOrJd65nK/l9Sjmpwtk21RrDKs13DCY/19ubP17updYUWBxp3VNfyfN3DAQKOA==", "cpu": [ "x64" ], @@ -422,9 +422,9 @@ } }, "node_modules/@github/copilot-linux-arm64": { - "version": "1.0.55-5", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.55-5.tgz", - "integrity": "sha512-UPZ5Y5QotcZvo3f4yFwJVOtAgUT3mq+q2fim82kWa/MA0+EkkADZ3kb+R4OnV1Nqv5EaoZiCFh0Ukk++IMSYwQ==", + "version": "1.0.60", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.60.tgz", + "integrity": "sha512-AVahkDVQTiGmHvDjlb4CHO8CFEGqmCEipxi0qTA60oH3Y3W2C4aYBwEBtP/85pN3wUUKZJVrWTCcxdufUBuK2Q==", "cpu": [ "arm64" ], @@ -441,9 +441,9 @@ } }, "node_modules/@github/copilot-linux-x64": { - "version": "1.0.55-5", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.55-5.tgz", - "integrity": "sha512-Fdwiir53Ogg8C9xv6sTc7/C4vFfQHt6VWFB74kojbDgIbYEpm57wNygQVwJvrwtVW3w/b1MLtGGTp7pEvUBACQ==", + "version": "1.0.60", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.60.tgz", + "integrity": "sha512-NwQjV2ZyUdJVAO4t7wiT+eR3uNWYP57xaLUIhf6JTMGpsTyN+mAFXW63xpwM/K+Pug62uRDQDBjEeOQRB7qZrA==", "cpu": [ "x64" ], @@ -460,9 +460,9 @@ } }, "node_modules/@github/copilot-linuxmusl-arm64": { - "version": "1.0.55-5", - "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.55-5.tgz", - "integrity": "sha512-NqPmeAA1+iI8Xd4wJUHNNCmVTmHCl+R3nqdXhEVQDLIau9ouGqGGay/91d2ZIgFXJn7J0UTAEdHbdBcfhbnhvg==", + "version": "1.0.60", + "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.60.tgz", + "integrity": "sha512-AYGPc9vq2k248bVwUbiVJ65kIYYMQQ7ci+S3oefWBIyYtYwAH0n+Q/IGAj49IPrelBarYABAsX+EQZJJC8rhxw==", "cpu": [ "arm64" ], @@ -479,9 +479,9 @@ } }, "node_modules/@github/copilot-linuxmusl-x64": { - "version": "1.0.55-5", - "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.55-5.tgz", - "integrity": "sha512-bOB4vKw1R7Mekn8z34xpNViYUQ4LQAEFzpkyxhc0uOliFmfku/YcIgo42aMWFzf/Bi3iBazBNfCN+L2lz/Jc9A==", + "version": "1.0.60", + "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.60.tgz", + "integrity": "sha512-9/F7yl0/9FpGvYR/TCQtbhu0vIaUVem6U7em85QYaEjkS45nK500pByCMWY0bXv2eSS8U2g+8FOAjfkyLlxwPw==", "cpu": [ "x64" ], @@ -498,12 +498,12 @@ } }, "node_modules/@github/copilot-sdk": { - "version": "1.0.0-beta.8", - "resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.0-beta.8.tgz", - "integrity": "sha512-lAuBfH6E5PUaSj8P/0FVMxzvwwBUs02tlvQ56PoJFtuc47KPqzGpf9BS7+h2eEr1UmjoLNJ/yqDiVApH9Oo1Fg==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.0.tgz", + "integrity": "sha512-OKjmJMDM+GB2uHr8UA6O0FNs1Gfw/tkoE5vUNlYmKbydc9Yjf6pvuBdseGjAVvzc6f9HIbB5eZKLUrxbOTw+yA==", "license": "MIT", "dependencies": { - "@github/copilot": "^1.0.55-1", + "@github/copilot": "^1.0.57", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" }, @@ -512,9 +512,9 @@ } }, "node_modules/@github/copilot-win32-arm64": { - "version": "1.0.55-5", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.55-5.tgz", - "integrity": "sha512-pR2KaiXUanjxolaWgRPlFdeTEpb7jcN1Rk8xVnBCD2ORwERXdYrqXaLCyDbgdplI9mI6IjM+kkUbyXzXoWz/HQ==", + "version": "1.0.60", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.60.tgz", + "integrity": "sha512-ZxxS+Ua1+7Puz80yTOpQ4WS+s32NjrxIsqo8gE0FpuZId16BGOGbWkzWQvR/k2AVBCqpLZ7SK3LfDVKuKJRbpA==", "cpu": [ "arm64" ], @@ -528,9 +528,9 @@ } }, "node_modules/@github/copilot-win32-x64": { - "version": "1.0.55-5", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.55-5.tgz", - "integrity": "sha512-EuQBgqSnRFjavgeFifbnSYUJ4elTQBLC/kf+WHolrHR2oUGyiqCQZz/cV2DYVSLP1TGxDKAV4AQCM1AdUT1xEA==", + "version": "1.0.60", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.60.tgz", + "integrity": "sha512-e91ZlFz9J1lkadExLg36oN8Ms/xIa03vAEir3DmyCeYebZ+Y48vdS+BwhQEma+GLoxJUOhzHndCckGnMRfNIbA==", "cpu": [ "x64" ], diff --git a/package.json b/package.json index 6786c68..4b4aedb 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,7 @@ "node": ">=24.0.0" }, "dependencies": { - "@github/copilot-sdk": "1.0.0-beta.8", + "@github/copilot-sdk": "^1.0.0", "@sveltejs/adapter-node": "^5.5.4", "@sveltejs/kit": "^2.61.1", "dompurify": "^3.4.7", diff --git a/src/lib/server/copilot/session.test.ts b/src/lib/server/copilot/session.test.ts index f993757..a1fb8eb 100644 --- a/src/lib/server/copilot/session.test.ts +++ b/src/lib/server/copilot/session.test.ts @@ -63,7 +63,7 @@ describe('createCopilotSession', () => { clientName: 'copilot-unleashed', model: 'gpt-4.1', streaming: true, - configDir: '/copilot-config', + configDirectory: '/copilot-config', }); const mcpServers = sessionConfig.mcpServers as Record>; @@ -124,7 +124,7 @@ describe('createCopilotSession', () => { excludedTools: ['bash'], availableTools: ['read'], onUserInputRequest, - configDir: '/custom-config', + configDirectory: '/custom-config', systemMessage: { mode: 'append', content: 'Stay concise.', diff --git a/src/lib/server/copilot/session.ts b/src/lib/server/copilot/session.ts index b16872d..b54054b 100644 --- a/src/lib/server/copilot/session.ts +++ b/src/lib/server/copilot/session.ts @@ -439,7 +439,7 @@ export async function createCopilotSession( model: options.model || 'gpt-4.1', streaming: true, onPermissionRequest: permissionHandler, - ...(config.copilotConfigDir && { configDir: config.copilotConfigDir }), + ...(config.copilotConfigDir && { configDirectory: config.copilotConfigDir }), mcpServers: await buildSessionMcpServers(githubToken, options.configDir), }; @@ -486,7 +486,7 @@ export async function createCopilotSession( } if (options.configDir) { - sessionConfig.configDir = options.configDir; + sessionConfig.configDirectory = options.configDir; } if (options.skillDirectories && options.skillDirectories.length > 0) { diff --git a/src/lib/server/ws/message-handlers/resume-session.ts b/src/lib/server/ws/message-handlers/resume-session.ts index c725886..ab69818 100644 --- a/src/lib/server/ws/message-handlers/resume-session.ts +++ b/src/lib/server/ws/message-handlers/resume-session.ts @@ -61,7 +61,7 @@ export async function handleResumeSession(msg: any, ctx: MessageContext): Promis streaming: true, onUserInputRequest: makeUserInputHandler(connectionEntry, ctx.userLogin), hooks: buildSessionHooks((message) => poolSend(connectionEntry, message)), - configDir: resolvedConfigDir, + configDirectory: resolvedConfigDir, mcpServers: mcpServersConfig as any, onEvent, ...(msg.modelCapabilities ? { modelCapabilities: msg.modelCapabilities } : {}), diff --git a/src/lib/server/ws/message-handlers/session-management.ts b/src/lib/server/ws/message-handlers/session-management.ts index 831cdac..bbde5a2 100644 --- a/src/lib/server/ws/message-handlers/session-management.ts +++ b/src/lib/server/ws/message-handlers/session-management.ts @@ -140,7 +140,7 @@ export async function handleGetSessionHistory(msg: any, ctx: MessageContext): Pr } try { - const events = await session.getMessages(); + const events = await session.getEvents(); const eventList = Array.isArray(events) ? events : []; debug('[SESSION_HISTORY] Got', eventList.length, 'events'); From bf1e9f75d7ab8eb3add7507e48e611d0541939b5 Mon Sep 17 00:00:00 2001 From: devartifex Date: Tue, 9 Jun 2026 23:30:23 +0200 Subject: [PATCH 02/14] feat(server): empty client mode, cloud sessions, remote toggle, tool failure surfacing - mode:'empty' with explicit feature re-enables (COPILOT_CLIENT_MODE escape hatch) - new_cloud_session WS handler (cloud agent sessions with repository binding) - remote_toggle WS handler via session.rpc.remote enable/disable - session.info infoType=remote -> remote_session_url message - tool.execution_complete success/error -> failed tool state in UI - onPostToolUseFailure hook -> hook_tool_failure message - continuePendingWork:true on resume Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/lib/components/chat/ChatMessage.svelte | 1 + src/lib/components/chat/ToolCall.svelte | 13 +- src/lib/server/config.ts | 5 + src/lib/server/copilot/client.ts | 9 +- src/lib/server/copilot/session.ts | 50 ++++++- src/lib/server/ws/constants.ts | 5 +- .../ws/message-handlers/cloud-session.ts | 133 ++++++++++++++++++ src/lib/server/ws/message-handlers/index.ts | 4 + src/lib/server/ws/message-handlers/remote.ts | 48 +++++++ .../ws/message-handlers/resume-session.ts | 6 +- src/lib/server/ws/session-events.ts | 18 ++- src/lib/stores/chat.svelte.ts | 9 +- src/lib/types/chat.ts | 3 + src/lib/types/client-messages.ts | 19 +++ src/lib/types/server-messages.ts | 39 +++++ 15 files changed, 350 insertions(+), 12 deletions(-) create mode 100644 src/lib/server/ws/message-handlers/cloud-session.ts create mode 100644 src/lib/server/ws/message-handlers/remote.ts diff --git a/src/lib/components/chat/ChatMessage.svelte b/src/lib/components/chat/ChatMessage.svelte index 3c9b6eb..48aee74 100644 --- a/src/lib/components/chat/ChatMessage.svelte +++ b/src/lib/components/chat/ChatMessage.svelte @@ -81,6 +81,7 @@ mcpToolName: message.mcpToolName, status: message.toolStatus ?? 'running', message: message.toolProgressMessage, + error: message.toolError, progressMessages: message.toolProgressMessages, }; }); diff --git a/src/lib/components/chat/ToolCall.svelte b/src/lib/components/chat/ToolCall.svelte index d84cc8f..290f92b 100644 --- a/src/lib/components/chat/ToolCall.svelte +++ b/src/lib/components/chat/ToolCall.svelte @@ -26,7 +26,6 @@ if (tool.status === 'failed') return 'failed'; return ''; }); - function toggle() { if (hasProgress) expanded = !expanded; } @@ -78,6 +77,9 @@ {/if} {/if} + {#if tool.status === 'failed' && tool.error} + + {/if} diff --git a/src/lib/server/config.ts b/src/lib/server/config.ts index 597f090..fc3879a 100644 --- a/src/lib/server/config.ts +++ b/src/lib/server/config.ts @@ -41,6 +41,11 @@ function getConfig() { otelCaptureContent: process.env.OTEL_CAPTURE_CONTENT === 'true', otelSourceName: env('OTEL_SOURCE_NAME', 'copilot-unleashed'), enableRemoteSessions: process.env.ENABLE_REMOTE_SESSIONS?.trim().toLowerCase() !== 'false', + // SDK client mode: "empty" (safe, explicit opt-in — default) or "copilot-cli" + // (legacy escape hatch giving the agent CLI-equivalent ambient capabilities). + copilotClientMode: (process.env.COPILOT_CLIENT_MODE?.trim().toLowerCase() === 'copilot-cli' + ? 'copilot-cli' + : 'empty') as 'empty' | 'copilot-cli', }; } diff --git a/src/lib/server/copilot/client.ts b/src/lib/server/copilot/client.ts index 37bc096..b7406be 100644 --- a/src/lib/server/copilot/client.ts +++ b/src/lib/server/copilot/client.ts @@ -1,4 +1,5 @@ import { homedir } from 'node:os'; +import { join } from 'node:path'; import { CopilotClient, RuntimeConnection } from '@github/copilot-sdk'; import type { TelemetryConfig } from '@github/copilot-sdk'; import { config } from '../config.js'; @@ -15,11 +16,17 @@ function buildTelemetryConfig(): TelemetryConfig | undefined { export function createCopilotClient(githubToken: string, configDir?: string): CopilotClient { const telemetry = buildTelemetryConfig(); + // Empty mode requires an explicit persistence location, so always resolve one. + const baseDirectory = configDir || config.copilotConfigDir || join(homedir(), '.copilot'); + return new CopilotClient({ connection: RuntimeConnection.forStdio(), gitHubToken: githubToken, workingDirectory: config.copilotCwd || homedir(), - ...(configDir && { baseDirectory: configDir }), + // "empty" mode disables the CLI's ambient host capabilities by default; + // each session explicitly opts back into the features this app uses. + mode: config.copilotClientMode, + baseDirectory, ...(telemetry && { telemetry }), enableRemoteSessions: config.enableRemoteSessions, }); diff --git a/src/lib/server/copilot/session.ts b/src/lib/server/copilot/session.ts index b54054b..ff8891c 100644 --- a/src/lib/server/copilot/session.ts +++ b/src/lib/server/copilot/session.ts @@ -2,8 +2,8 @@ import { homedir } from 'node:os'; import { join } from 'node:path'; import { readFile, writeFile, rename } from 'node:fs/promises'; import { createHash, randomUUID } from 'node:crypto'; -import { CopilotClient } from '@github/copilot-sdk'; -import type { SessionConfig, SystemMessageSection, SectionOverride, MCPServerConfig, ModelCapabilitiesOverride, RemoteSessionMode } from '@github/copilot-sdk'; +import { CopilotClient, ToolSet } from '@github/copilot-sdk'; +import type { SessionConfig, SystemMessageSection, SectionOverride, MCPServerConfig, ModelCapabilitiesOverride, RemoteSessionMode, CloudSessionOptions } from '@github/copilot-sdk'; export type HookEventCallback = (message: Record) => void; import { isIP } from 'node:net'; @@ -46,6 +46,8 @@ export interface CreateSessionOptions { provider?: SessionConfig['provider']; onElicitationRequest?: SessionConfig['onElicitationRequest']; remoteSession?: RemoteSessionMode; + /** Create the session on GitHub's cloud agent infrastructure instead of locally */ + cloud?: CloudSessionOptions; } function isPrivateIpv4(hostname: string): boolean { @@ -386,14 +388,51 @@ export async function buildSessionMcpServers( }; } -export function buildSessionHooks(onHookEvent: HookEventCallback): SessionConfig['hooks'] { +/** + * Explicit re-enables for the SDK's "empty" client mode. + * + * Empty mode disables the CLI's ambient capabilities by default (safe for + * multi-user servers). This app opts back into exactly the features it uses, + * keeping behavior parity with the previous "copilot-cli" mode while every + * capability stays an explicit, auditable choice. + */ +export function buildEmptyModeSessionDefaults(): Partial { + if (config.copilotClientMode !== 'empty') return {}; return { + // Empty mode requires every session to opt into its tools explicitly. + availableTools: new ToolSet().addBuiltIn('*').addMcp('*').addCustom('*').toArray(), + enableSkills: true, + enableConfigDiscovery: true, + enableHostGitOperations: true, + enableSessionStore: true, + enableOnDemandInstructionDiscovery: true, + // Keep MCP OAuth tokens on disk so the Copilot CLI stays in sync + mcpOAuthTokenStorage: 'persistent', + embeddingCacheStorage: 'persistent', + skipEmbeddingRetrieval: false, + skipCustomInstructions: false, + coauthorEnabled: true, + // Deliberately left at empty-mode defaults (off): enableFileHooks + // (hooks are wired via SDK callbacks), enableSessionTelemetry, + // customAgentsLocalOnly stays true, installedPlugins stays []. + }; +} + +export function buildSessionHooks(onHookEvent: HookEventCallback): SessionConfig['hooks'] { return { onPreToolUse: (input) => { onHookEvent({ type: 'hook_pre_tool', toolName: input.toolName, toolArgs: input.toolArgs }); }, onPostToolUse: (input) => { onHookEvent({ type: 'hook_post_tool', toolName: input.toolName, toolArgs: input.toolArgs }); }, + onPostToolUseFailure: (input) => { + onHookEvent({ + type: 'hook_tool_failure', + toolName: input.toolName, + toolArgs: input.toolArgs, + error: input.error, + }); + }, onUserPromptSubmitted: (input) => { onHookEvent({ type: 'hook_user_prompt', prompt: input.prompt }); }, @@ -435,6 +474,7 @@ export async function createCopilotSession( console.log('[SESSION] Creating session with permissionMode:', options.permissionMode || 'approve_all (default)'); const sessionConfig: SessionConfig = { + ...buildEmptyModeSessionDefaults(), clientName: 'copilot-unleashed', model: options.model || 'gpt-4.1', streaming: true, @@ -529,6 +569,10 @@ export async function createCopilotSession( sessionConfig.remoteSession = options.remoteSession; } + if (options.cloud) { + sessionConfig.cloud = options.cloud; + } + // The SDK 1.0.0-beta runtime writes session state directly to // `/session-state/` (set via CopilotClient `baseDirectory`), // so an explicit per-session FS provider is no longer required. diff --git a/src/lib/server/ws/constants.ts b/src/lib/server/ws/constants.ts index f8ac950..50db95c 100644 --- a/src/lib/server/ws/constants.ts +++ b/src/lib/server/ws/constants.ts @@ -4,7 +4,7 @@ import { tmpdir } from 'node:os'; export const MAX_MESSAGE_LENGTH = 10_000; export const VALID_MESSAGE_TYPES = new Set([ - 'new_session', 'message', 'list_models', 'set_mode', + 'new_session', 'new_cloud_session', 'message', 'list_models', 'set_mode', 'abort', 'set_model', 'set_reasoning', 'user_input_response', 'permission_response', 'elicitation_response', 'ping', 'list_tools', 'list_agents', 'select_agent', 'deselect_agent', @@ -18,6 +18,7 @@ export const VALID_MESSAGE_TYPES = new Set([ 'workspace_list_files', 'workspace_read_file', 'workspace_create_file', 'clear_chat', 'get_session_history', 'session_log', + 'remote_toggle', ]); export const VALID_MODES = new Set(['interactive', 'plan', 'autopilot']); @@ -31,6 +32,6 @@ export const HEARTBEAT_INTERVAL = 30_000; export const MAX_MISSED_PINGS = 3; export const UPLOAD_DIR_PREFIX = join(tmpdir(), 'copilot-uploads'); -export const RATE_LIMITED_TYPES = new Set(['message', 'new_session', 'resume_session', 'compact', 'start_fleet']); +export const RATE_LIMITED_TYPES = new Set(['message', 'new_session', 'new_cloud_session', 'resume_session', 'compact', 'start_fleet']); export const WS_RATE_LIMIT_MAX = 30; export const WS_RATE_LIMIT_WINDOW_MS = 60_000; diff --git a/src/lib/server/ws/message-handlers/cloud-session.ts b/src/lib/server/ws/message-handlers/cloud-session.ts new file mode 100644 index 0000000..d8b3f47 --- /dev/null +++ b/src/lib/server/ws/message-handlers/cloud-session.ts @@ -0,0 +1,133 @@ +import { createCopilotSession } from '../../copilot/session.js'; +import { chatStateStore } from '../../chat-state-singleton.js'; +import { config } from '../../config.js'; +import { poolSend } from '../session-pool.js'; +import { VALID_MODES } from '../constants.js'; +import { wireSessionEvents, createCatchAllHandler, HANDLED_EVENT_TYPES } from '../session-events.js'; +import { makeUserInputHandler, makePermissionHandler, makeElicitationHandler } from '../permissions.js'; +import { getSkillDirectories } from '../../skills/scanner.js'; +import type { MessageContext } from '../types.js'; + +// GitHub owner: alphanumeric + hyphens, no leading/trailing hyphen, max 39 chars. +const OWNER_RE = /^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,37}[a-zA-Z0-9])?$/; +// GitHub repo name: alphanumeric, hyphen, underscore, dot; max 100 chars. +const REPO_RE = /^[a-zA-Z0-9._-]{1,100}$/; +// Git branch: conservative allowlist (no control chars, spaces, or git-invalid sequences). +const BRANCH_RE = /^[a-zA-Z0-9](?:[a-zA-Z0-9._\/-]{0,254})$/; + +function rawTabId(ctx: MessageContext): string { + return ctx.poolKey.split(':').slice(1).join(':'); +} + +interface CloudRepositoryInput { + owner: string; + name: string; + branch?: string; +} + +function parseRepository(raw: unknown): CloudRepositoryInput | { error: string } | null { + if (raw == null) return null; + if (typeof raw !== 'object') return { error: 'repository must be an object' }; + + const obj = raw as Record; + const owner = typeof obj.owner === 'string' ? obj.owner.trim() : ''; + const name = typeof obj.name === 'string' ? obj.name.trim() : ''; + const branch = typeof obj.branch === 'string' ? obj.branch.trim() : undefined; + + if (!OWNER_RE.test(owner)) return { error: 'Invalid repository owner' }; + if (!REPO_RE.test(name)) return { error: 'Invalid repository name' }; + if (branch !== undefined && branch !== '' && !BRANCH_RE.test(branch)) { + return { error: 'Invalid branch name' }; + } + + return { owner, name, ...(branch ? { branch } : {}) }; +} + +/** + * Creates a session that runs on GitHub's cloud agent infrastructure + * instead of locally. The session ID is assigned server-side by GitHub. + */ +export async function handleNewCloudSession(msg: any, ctx: MessageContext): Promise { + const { connectionEntry, githubToken } = ctx; + + if (!config.enableRemoteSessions) { + poolSend(connectionEntry, { type: 'error', message: 'Remote sessions are disabled on this server' }); + return; + } + + const repository = parseRepository(msg.repository); + if (repository && 'error' in repository) { + poolSend(connectionEntry, { type: 'error', message: repository.error }); + return; + } + + // Delete old persisted state before creating new session + chatStateStore.delete(ctx.userLogin, rawTabId(ctx)); + + if (connectionEntry.session) { + try { await connectionEntry.session.disconnect(); } catch { /* ignore */ } + connectionEntry.session = null; + } + connectionEntry.userInputResolve = null; + connectionEntry.permissionResolves.clear(); + connectionEntry.pendingUserInputPrompt = null; + connectionEntry.pendingPermissionPrompts.clear(); + + try { + const skillDirectories = await getSkillDirectories(); + const onEvent = createCatchAllHandler(connectionEntry, HANDLED_EVENT_TYPES); + + connectionEntry.session = await createCopilotSession(connectionEntry.client, githubToken, { + model: msg.model, + reasoningEffort: msg.reasoningEffort, + onUserInputRequest: makeUserInputHandler(connectionEntry, ctx.userLogin), + permissionMode: msg.mode === 'autopilot' ? 'approve_all' : 'prompt', + onPermissionRequest: makePermissionHandler(connectionEntry, ctx.userLogin), + onElicitationRequest: makeElicitationHandler(connectionEntry, ctx.userLogin), + configDir: config.copilotConfigDir, + skillDirectories, + onEvent, + cloud: repository ? { repository } : {}, + onHookEvent: (message) => poolSend(connectionEntry, message), + }); + + wireSessionEvents(connectionEntry.session, connectionEntry, connectionEntry.session?.sessionId, ctx.userLogin, rawTabId(ctx)); + + if (msg.mode && VALID_MODES.has(msg.mode)) { + try { + await connectionEntry.session.rpc.mode.set({ mode: msg.mode }); + } catch (modeErr: any) { + console.warn('Initial mode set failed for cloud session:', modeErr.message); + } + } + + const sessionId = connectionEntry.session?.sessionId; + poolSend(connectionEntry, { + type: 'cloud_session_created', + sessionId, + model: msg.model, + ...(repository ? { repository } : {}), + }); + + connectionEntry.sdkSessionId = sessionId ?? null; + connectionEntry.model = msg.model ?? null; + connectionEntry.mode = msg.mode ?? 'interactive'; + + chatStateStore.save(ctx.userLogin, rawTabId(ctx), { + userId: ctx.userLogin, + tabId: rawTabId(ctx), + sdkSessionId: sessionId ?? null, + model: msg.model ?? 'gpt-4.1', + mode: msg.mode ?? 'interactive', + messages: [], + createdAt: Date.now(), + updatedAt: Date.now(), + }).catch(() => {}); + } catch (err: any) { + console.error('Cloud session creation error:', err.message); + poolSend(connectionEntry, { + type: 'error', + message: `Failed to create cloud session: ${err.message}`, + }); + } +} diff --git a/src/lib/server/ws/message-handlers/index.ts b/src/lib/server/ws/message-handlers/index.ts index d9423d6..5008fe0 100644 --- a/src/lib/server/ws/message-handlers/index.ts +++ b/src/lib/server/ws/message-handlers/index.ts @@ -7,6 +7,8 @@ import { handleListTools, handleListAgents, handleSelectAgent, handleDeselectAge import { handleGetQuota, handleCompact } from './quota-compact.js'; import { handleListSessions, handleDeleteSession, handleGetSessionDetail, handleListModels, handleGetSessionHistory, handleSessionLog } from './session-management.js'; import { handleResumeSession } from './resume-session.js'; +import { handleNewCloudSession } from './cloud-session.js'; +import { handleRemoteToggle } from './remote.js'; import { handleGetPlan, handleUpdatePlan, handleDeletePlan } from './plans.js'; import { handleStartFleet } from './fleet.js'; import { handleListSkillsRpc, handleToggleSkillRpc, handleReloadSkills, handleListMcpRpc, handleToggleMcpRpc, handleListInstructions, handleListPrompts, handleUsePrompt } from './rpc-discovery.js'; @@ -17,6 +19,8 @@ import { chatStateStore } from '../../chat-state-singleton.js'; export const messageHandlers: Record Promise> = { new_session: handleNewSession, + new_cloud_session: handleNewCloudSession, + remote_toggle: handleRemoteToggle, message: handleChat, list_models: handleListModels, set_mode: handleSetMode, diff --git a/src/lib/server/ws/message-handlers/remote.ts b/src/lib/server/ws/message-handlers/remote.ts new file mode 100644 index 0000000..8f111b3 --- /dev/null +++ b/src/lib/server/ws/message-handlers/remote.ts @@ -0,0 +1,48 @@ +import { config } from '../../config.js'; +import { poolSend } from '../session-pool.js'; +import { debug } from '../../logger.js'; +import type { MessageContext } from '../types.js'; + +const VALID_REMOTE_MODES = new Set(['off', 'export', 'on']); + +/** + * Toggles remote session export/steering on the active session at runtime + * via the SDK's experimental `session.rpc.remote` surface. + * + * msg.mode: "off" disables; "export" publishes events to GitHub; + * "on" enables export + remote steering (github.com / Mobile). + */ +export async function handleRemoteToggle(msg: any, ctx: MessageContext): Promise { + const { connectionEntry } = ctx; + + if (!config.enableRemoteSessions) { + poolSend(connectionEntry, { type: 'error', message: 'Remote sessions are disabled on this server' }); + return; + } + + const session = connectionEntry.session; + if (!session) { + poolSend(connectionEntry, { type: 'error', message: 'No active session' }); + return; + } + + const mode = typeof msg.mode === 'string' && VALID_REMOTE_MODES.has(msg.mode) ? msg.mode : 'on'; + + try { + if (mode === 'off') { + await session.rpc.remote.disable(); + poolSend(connectionEntry, { type: 'remote_toggled', enabled: false }); + return; + } + + const result = await session.rpc.remote.enable({ mode }); + poolSend(connectionEntry, { type: 'remote_toggled', enabled: true }); + if (result?.url) { + poolSend(connectionEntry, { type: 'remote_session_url', url: result.url }); + } + debug('[REMOTE] Enabled mode:', mode, 'steerable:', result?.remoteSteerable, 'url:', result?.url); + } catch (err: any) { + console.error('[REMOTE] Toggle error:', err.message); + poolSend(connectionEntry, { type: 'error', message: `Failed to toggle remote session: ${err.message}` }); + } +} diff --git a/src/lib/server/ws/message-handlers/resume-session.ts b/src/lib/server/ws/message-handlers/resume-session.ts index ab69818..ac1f1b1 100644 --- a/src/lib/server/ws/message-handlers/resume-session.ts +++ b/src/lib/server/ws/message-handlers/resume-session.ts @@ -1,6 +1,6 @@ import { join } from 'node:path'; import { approveAll } from '@github/copilot-sdk'; -import { createCopilotSession, buildSessionHooks, buildSessionMcpServers } from '../../copilot/session.js'; +import { createCopilotSession, buildSessionHooks, buildSessionMcpServers, buildEmptyModeSessionDefaults } from '../../copilot/session.js'; import { getSessionDetail, buildSessionContext, isValidSessionId } from '../../copilot/session-metadata.js'; import { loadSessionTurns } from '../../copilot/session-store-db.js'; import { chatStateStore } from '../../chat-state-singleton.js'; @@ -57,8 +57,12 @@ export async function handleResumeSession(msg: any, ctx: MessageContext): Promis // Try native SDK resume first try { connectionEntry.session = await connectionEntry.client.resumeSession(sessionId, { + ...buildEmptyModeSessionDefaults(), onPermissionRequest: (await import('@github/copilot-sdk')).approveAll, streaming: true, + // Re-prompt any permission requests that were pending when the + // session was last suspended instead of dropping them. + continuePendingWork: true, onUserInputRequest: makeUserInputHandler(connectionEntry, ctx.userLogin), hooks: buildSessionHooks((message) => poolSend(connectionEntry, message)), configDirectory: resolvedConfigDir, diff --git a/src/lib/server/ws/session-events.ts b/src/lib/server/ws/session-events.ts index c04c855..e5169e0 100644 --- a/src/lib/server/ws/session-events.ts +++ b/src/lib/server/ws/session-events.ts @@ -108,8 +108,13 @@ export function wireSessionEvents( poolSend(entry, { type: 'tool_start', toolCallId: event.data.toolCallId, toolName: event.data.toolName, mcpServerName: event.data.mcpServerName, mcpToolName: event.data.mcpToolName }); }); session.on('tool.execution_complete', (event: any) => { - debug('[TOOL] execution_complete:', event.data.toolCallId); - poolSend(entry, { type: 'tool_end', toolCallId: event.data.toolCallId }); + debug('[TOOL] execution_complete:', event.data.toolCallId, 'success:', event.data.success); + poolSend(entry, { + type: 'tool_end', + toolCallId: event.data.toolCallId, + success: event.data.success !== false, + ...(event.data.error?.message ? { error: event.data.error.message } : {}), + }); }); session.on('tool.execution_progress', (event: any) => { debug('[TOOL] execution_progress:', event.data.toolCallId, event.data.message); @@ -177,7 +182,14 @@ export function wireSessionEvents( poolSend(entry, { type: 'subagent_end', agentName: event.data.agentName }); }); session.on('session.info', (event: any) => { - poolSend(entry, { type: 'info', message: event.data?.message || event.data }); + const infoType = event.data?.infoType; + const url = event.data?.url; + // Remote session export publishes a github.com URL for monitoring/steering + if (infoType === 'remote' && typeof url === 'string') { + poolSend(entry, { type: 'remote_session_url', url, message: event.data?.message }); + return; + } + poolSend(entry, { type: 'info', message: event.data?.message || event.data, ...(infoType ? { infoType } : {}), ...(url ? { url } : {}) }); }); session.on('session.plan_changed', (event: any) => { poolSend(entry, { type: 'plan_changed', content: event.data?.content, path: event.data?.path }); diff --git a/src/lib/stores/chat.svelte.ts b/src/lib/stores/chat.svelte.ts index 258bf37..75c0690 100644 --- a/src/lib/stores/chat.svelte.ts +++ b/src/lib/stores/chat.svelte.ts @@ -4,6 +4,7 @@ import type { ChatMessageRole, CopilotUsageItem, ToolCallState, + ToolCallStatus, ServerMessage, SessionMode, ReasoningEffort, @@ -319,7 +320,13 @@ export function createChatStore(wsStore: WsStore): ChatStore { case 'tool_end': messages = messages.map(m => - m.toolCallId === msg.toolCallId ? { ...m, toolStatus: 'complete' as const } : m, + m.toolCallId === msg.toolCallId + ? { + ...m, + toolStatus: (msg.success === false ? 'failed' : 'complete') as ToolCallStatus, + ...(msg.success === false && msg.error ? { toolError: msg.error } : {}), + } + : m, ); break; diff --git a/src/lib/types/chat.ts b/src/lib/types/chat.ts index a50e604..a56d3aa 100644 --- a/src/lib/types/chat.ts +++ b/src/lib/types/chat.ts @@ -31,6 +31,7 @@ export interface ChatMessage { toolCallId?: string; toolName?: string; toolStatus?: ToolCallStatus; + toolError?: string; toolProgressMessage?: string; toolProgressMessages?: string[]; mcpServerName?: string; @@ -59,5 +60,7 @@ export interface ToolCallState { mcpToolName?: string; status: ToolCallStatus; message?: string; + /** Error message when status is "failed" */ + error?: string; progressMessages?: string[]; } diff --git a/src/lib/types/client-messages.ts b/src/lib/types/client-messages.ts index 0002c34..9b5029a 100644 --- a/src/lib/types/client-messages.ts +++ b/src/lib/types/client-messages.ts @@ -15,6 +15,23 @@ export interface NewSessionMessage { systemPromptSections?: Record; modelCapabilities?: ModelCapabilitiesOverride; enableConfigDiscovery?: boolean; + /** "off" local only, "export" publish events to GitHub, "on" export + remote steering */ + remoteSession?: 'off' | 'export' | 'on'; +} + +/** Creates a session running on GitHub's cloud agent infrastructure */ +export interface NewCloudSessionMessage { + type: 'new_cloud_session'; + model?: string; + mode?: SessionMode; + reasoningEffort?: ReasoningEffort; + repository?: { owner: string; name: string; branch?: string }; +} + +/** Toggles remote export/steering on the active session */ +export interface RemoteToggleMessage { + type: 'remote_toggle'; + mode?: 'off' | 'export' | 'on'; } export interface SendMessage { @@ -227,6 +244,8 @@ export interface WorkspaceCreateFileMessage { export type ClientMessage = | NewSessionMessage + | NewCloudSessionMessage + | RemoteToggleMessage | SendMessage | ListModelsMessage | SetModeMessage diff --git a/src/lib/types/server-messages.ts b/src/lib/types/server-messages.ts index d18d52b..19a038c 100644 --- a/src/lib/types/server-messages.ts +++ b/src/lib/types/server-messages.ts @@ -89,6 +89,10 @@ export interface ToolProgressMessage { export interface ToolEndMessage { type: 'tool_end'; toolCallId: string; + /** False when the tool execution failed */ + success?: boolean; + /** Human-readable error message when success is false */ + error?: string; } export interface ModelsMessage { @@ -277,6 +281,29 @@ export interface SubagentDeselectedMessage { export interface InfoMessage { type: 'info'; message: string; + infoType?: string; + url?: string; +} + +/** Published when a remote-enabled session receives its github.com monitoring URL */ +export interface RemoteSessionUrlMessage { + type: 'remote_session_url'; + url: string; + message?: string; +} + +/** Result of toggling remote steering on the active session */ +export interface RemoteToggledMessage { + type: 'remote_toggled'; + enabled: boolean; +} + +/** Confirmation that a cloud session was created on GitHub's infrastructure */ +export interface CloudSessionCreatedMessage { + type: 'cloud_session_created'; + sessionId?: string; + model?: string; + repository?: { owner: string; name: string; branch?: string }; } export interface ElicitationRequestedMessage { @@ -393,6 +420,13 @@ export interface HookPostToolMessage { toolArgs?: unknown; } +export interface HookToolFailureMessage { + type: 'hook_tool_failure'; + toolName: string; + toolArgs?: unknown; + error: string; +} + export interface HookSessionStartMessage { type: 'hook_session_start'; source: string; @@ -418,6 +452,7 @@ export interface HookErrorMessage { export type HookMessage = | HookPreToolMessage | HookPostToolMessage + | HookToolFailureMessage | HookUserPromptMessage | HookSessionStartMessage | HookSessionEndMessage @@ -615,6 +650,9 @@ export type ServerMessage = | SubagentSelectedMessage | SubagentDeselectedMessage | InfoMessage + | RemoteSessionUrlMessage + | RemoteToggledMessage + | CloudSessionCreatedMessage | ElicitationRequestedMessage | ElicitationCompletedMessage | ExitPlanModeRequestedMessage @@ -633,6 +671,7 @@ export type ServerMessage = | SystemNotificationMessage | HookPreToolMessage | HookPostToolMessage + | HookToolFailureMessage | HookUserPromptMessage | HookSessionStartMessage | HookSessionEndMessage From d6e6b5d2d66e9216dca28e2fc05c20b0628c1b30 Mon Sep 17 00:00:00 2001 From: devartifex Date: Tue, 9 Jun 2026 23:36:56 +0200 Subject: [PATCH 03/14] =?UTF-8?q?feat:=20remote=20session=20UI=20=E2=80=94?= =?UTF-8?q?=20settings=20panel,=20remote=20URL=20banner,=20new-session=20w?= =?UTF-8?q?iring?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/lib/components/layout/RemoteBanner.svelte | 82 ++++++++++++ .../settings/RemoteSessionPanel.svelte | 123 ++++++++++++++++++ .../components/settings/SettingsModal.svelte | 36 ++++- src/lib/stores/chat.svelte.ts | 36 +++++ src/lib/stores/chat.test.ts | 2 + src/lib/stores/settings.svelte.ts | 15 +++ src/lib/stores/ws.svelte.ts | 21 +++ src/lib/types/config.ts | 14 +- src/routes/+page.svelte | 16 +++ 9 files changed, 343 insertions(+), 2 deletions(-) create mode 100644 src/lib/components/layout/RemoteBanner.svelte create mode 100644 src/lib/components/settings/RemoteSessionPanel.svelte diff --git a/src/lib/components/layout/RemoteBanner.svelte b/src/lib/components/layout/RemoteBanner.svelte new file mode 100644 index 0000000..b45b367 --- /dev/null +++ b/src/lib/components/layout/RemoteBanner.svelte @@ -0,0 +1,82 @@ + + +
+
+ + diff --git a/src/lib/components/settings/RemoteSessionPanel.svelte b/src/lib/components/settings/RemoteSessionPanel.svelte new file mode 100644 index 0000000..b1574ce --- /dev/null +++ b/src/lib/components/settings/RemoteSessionPanel.svelte @@ -0,0 +1,123 @@ + + +

+ Remote sessions publish your conversation to github.com so you can view or steer it from other devices. + This setting applies to new sessions. +

+ +
+ {#each MODES as mode (mode.value)} + + {/each} +
+ +{#if sessionActive && onApplyToSession} + +{/if} + + diff --git a/src/lib/components/settings/SettingsModal.svelte b/src/lib/components/settings/SettingsModal.svelte index 5fbad17..175bbc0 100644 --- a/src/lib/components/settings/SettingsModal.svelte +++ b/src/lib/components/settings/SettingsModal.svelte @@ -19,8 +19,10 @@ import NotificationsPanel from './NotificationsPanel.svelte'; import CompactionPanel from './CompactionPanel.svelte'; import ByokPanel from './ByokPanel.svelte'; + import RemoteSessionPanel from './RemoteSessionPanel.svelte'; + import type { RemoteSessionMode } from '$lib/types/index.js'; - type AccordionSection = 'instructions' | 'tools' | 'mcp' | 'agents' | 'skills' | 'extensions' | 'quota' | 'notifications' | 'compact' | 'prompts' | 'byok' | null; + type AccordionSection = 'instructions' | 'tools' | 'mcp' | 'agents' | 'skills' | 'extensions' | 'quota' | 'notifications' | 'remote' | 'compact' | 'prompts' | 'byok' | null; interface Props { open: boolean; @@ -55,6 +57,10 @@ onToggleMcpServer: (name: string, enabled: boolean) => void; notificationsEnabled: boolean; onToggleNotifications: (enabled: boolean) => void; + remoteSessionMode?: RemoteSessionMode; + onSetRemoteSessionMode?: (mode: RemoteSessionMode) => void; + remoteSessionActive?: boolean; + onApplyRemoteToSession?: (mode: RemoteSessionMode) => void; voiceInputEnabled: boolean; onToggleVoiceInput: (enabled: boolean) => void; ttsEnabled: boolean; @@ -98,6 +104,10 @@ onToggleMcpServer, notificationsEnabled, onToggleNotifications, + remoteSessionMode = 'off', + onSetRemoteSessionMode, + remoteSessionActive = false, + onApplyRemoteToSession, voiceInputEnabled, onToggleVoiceInput, ttsEnabled, @@ -379,6 +389,30 @@ {/if} + + {#if onSetRemoteSessionMode} +
+ + {#if activeSection === 'remote'} +
+ +
+ {/if} +
+ {/if} +
diff --git a/src/lib/stores/chat.svelte.ts b/src/lib/stores/chat.svelte.ts index 75c0690..9cf1f23 100644 --- a/src/lib/stores/chat.svelte.ts +++ b/src/lib/stores/chat.svelte.ts @@ -45,6 +45,10 @@ export interface ChatStore { readonly fleetActive: boolean; readonly fleetAgents: Array<{ agentId: string; agentType: string; status: 'running' | 'completed' | 'failed'; error?: string }>; readonly sessionTitle: string | null; + /** github.com URL when the session is exported/steerable remotely */ + readonly remoteUrl: string | null; + /** True when the active session runs on GitHub's cloud agent */ + readonly isCloudSession: boolean; readonly pendingUserInput: UserInputState | null; readonly pendingElicitation: ElicitationState | null; readonly pendingPermissions: PermissionRequestState[]; @@ -111,6 +115,9 @@ export function createChatStore(wsStore: WsStore): ChatStore { let fleetAgents = $state>([]); let sessionTitle = $state(null); let currentSessionId = $state(null); + // Remote/cloud session state + let remoteUrl = $state(null); + let isCloudSession = $state(false); let pendingUserInput = $state(null); let pendingElicitation = $state(null); let pendingPermissions = $state([]); @@ -250,10 +257,37 @@ export function createChatStore(wsStore: WsStore): ChatStore { currentModel = msg.model; if (msg.sessionId) currentSessionId = msg.sessionId; plan = { exists: false, content: '' }; + isCloudSession = false; + remoteUrl = null; wsStore.getQuota(); wsStore.listSessions(); break; + case 'cloud_session_created': { + if (msg.model) currentModel = msg.model; + if (msg.sessionId) currentSessionId = msg.sessionId; + plan = { exists: false, content: '' }; + isCloudSession = true; + remoteUrl = null; + const repo = msg.repository ? ` for ${msg.repository.owner}/${msg.repository.name}${msg.repository.branch ? `@${msg.repository.branch}` : ''}` : ''; + addInfoMessage(`Cloud session created${repo} — running on GitHub's cloud agent`); + wsStore.getQuota(); + wsStore.listSessions(); + break; + } + + case 'remote_session_url': + remoteUrl = msg.url; + addInfoMessage(msg.message || `Session available on GitHub: ${msg.url}`); + break; + + case 'remote_toggled': + if (!msg.enabled) { + remoteUrl = null; + addInfoMessage('Remote session disabled'); + } + break; + case 'session_reconnected': if (msg.hasSession) { addInfoMessage('Session reconnected'); @@ -925,6 +959,8 @@ export function createChatStore(wsStore: WsStore): ChatStore { get fleetActive() { return fleetActive; }, get fleetAgents() { return fleetAgents; }, get sessionTitle() { return sessionTitle; }, + get remoteUrl() { return remoteUrl; }, + get isCloudSession() { return isCloudSession; }, get pendingUserInput() { return pendingUserInput; }, get pendingElicitation() { return pendingElicitation; }, get pendingPermissions() { return pendingPermissions; }, diff --git a/src/lib/stores/chat.test.ts b/src/lib/stores/chat.test.ts index 932265d..5102d7e 100644 --- a/src/lib/stores/chat.test.ts +++ b/src/lib/stores/chat.test.ts @@ -36,6 +36,8 @@ function createWsStoreMock(options: { send: vi.fn(), sendMessage: vi.fn(), newSession: vi.fn(), + newCloudSession: vi.fn(), + remoteToggle: vi.fn(), resumeSession: vi.fn(), setMode: vi.fn(), setModel: vi.fn(), diff --git a/src/lib/stores/settings.svelte.ts b/src/lib/stores/settings.svelte.ts index 170b6b4..cf47ba3 100644 --- a/src/lib/stores/settings.svelte.ts +++ b/src/lib/stores/settings.svelte.ts @@ -28,8 +28,11 @@ const DEFAULT_SETTINGS: PersistedSettings = { voiceInputEnabled: true, ttsEnabled: true, ttsRate: 1.0, + remoteSession: 'off', }; +const VALID_REMOTE_SESSION = new Set>(['off', 'export', 'on']); + const VALID_MODES = new Set(['interactive', 'plan', 'autopilot']); const VALID_REASONING = new Set(['low', 'medium', 'high', 'xhigh']); @@ -49,6 +52,7 @@ export interface SettingsStore { voiceInputEnabled: boolean; ttsEnabled: boolean; ttsRate: number; + remoteSession: NonNullable; load(): void; save(): void; syncFromServer(): Promise; @@ -72,6 +76,7 @@ export function createSettingsStore(): SettingsStore { let voiceInputEnabled = $state(DEFAULT_SETTINGS.voiceInputEnabled ?? true); let ttsEnabled = $state(DEFAULT_SETTINGS.ttsEnabled ?? true); let ttsRate = $state(DEFAULT_SETTINGS.ttsRate ?? 1.0); + let remoteSession = $state>(DEFAULT_SETTINGS.remoteSession ?? 'off'); // Detect a usable browser localStorage. Node 25+ exposes a built-in // `localStorage` global as a stub when `--localstorage-file` is not set; @@ -104,6 +109,7 @@ export function createSettingsStore(): SettingsStore { voiceInputEnabled, ttsEnabled, ttsRate, + remoteSession, }; } @@ -147,6 +153,9 @@ export function createSettingsStore(): SettingsStore { if (typeof parsed.ttsRate === 'number') { ttsRate = Math.max(0.5, Math.min(2, parsed.ttsRate)); } + if (parsed.remoteSession && VALID_REMOTE_SESSION.has(parsed.remoteSession)) { + remoteSession = parsed.remoteSession; + } } function save(): void { @@ -288,6 +297,12 @@ export function createSettingsStore(): SettingsStore { get ttsRate() { return ttsRate; }, set ttsRate(v: number) { ttsRate = Math.max(0.5, Math.min(2, v)); save(); }, + get remoteSession() { return remoteSession; }, + set remoteSession(v: NonNullable) { + remoteSession = VALID_REMOTE_SESSION.has(v) ? v : 'off'; + save(); + }, + load, save, syncFromServer, diff --git a/src/lib/stores/ws.svelte.ts b/src/lib/stores/ws.svelte.ts index e9c3be0..8125543 100644 --- a/src/lib/stores/ws.svelte.ts +++ b/src/lib/stores/ws.svelte.ts @@ -6,6 +6,7 @@ import type { ClientMessage, ServerMessage, NewSessionConfig, + CloudSessionConfig, MessageDeliveryMode, } from '$lib/types/index.js'; import { notify } from '$lib/utils/notifications.js'; @@ -57,6 +58,8 @@ export interface WsStore { mode?: MessageDeliveryMode, ): void; newSession(config: NewSessionConfig): void; + newCloudSession(config: CloudSessionConfig): void; + remoteToggle(mode?: 'off' | 'export' | 'on'): void; resumeSession(sessionId: string): void; setMode(mode: SessionMode): void; setModel(model: string): void; @@ -403,10 +406,26 @@ export function createWsStore(): WsStore { ...(config.customInstructions?.trim() && { customInstructions: config.customInstructions.trim() }), ...(config.excludedTools?.length && { excludedTools: config.excludedTools }), ...(config.infiniteSessions && { infiniteSessions: config.infiniteSessions }), + ...(config.remoteSession && config.remoteSession !== 'off' && { remoteSession: config.remoteSession }), }; send(msg); } + function newCloudSession(config: CloudSessionConfig): void { + sessionReady = false; + send({ + type: 'new_cloud_session', + ...(config.model && { model: config.model }), + ...(config.mode && { mode: config.mode }), + ...(config.reasoningEffort && { reasoningEffort: config.reasoningEffort }), + ...(config.repository && { repository: config.repository }), + }); + } + + function remoteToggle(mode?: 'off' | 'export' | 'on'): void { + send({ type: 'remote_toggle', ...(mode ? { mode } : {}) }); + } + function resumeSession(sessionId: string): void { sessionReady = false; send({ type: 'resume_session', sessionId }); @@ -508,6 +527,8 @@ export function createWsStore(): WsStore { send, sendMessage, newSession, + newCloudSession, + remoteToggle, resumeSession, setMode, setModel, diff --git a/src/lib/types/config.ts b/src/lib/types/config.ts index cbe3b05..6b7961d 100644 --- a/src/lib/types/config.ts +++ b/src/lib/types/config.ts @@ -15,6 +15,9 @@ export interface SystemPromptSectionInput { content?: string; } +/** "off" local only, "export" publish events to GitHub, "on" export + remote steering */ +export type RemoteSessionMode = 'off' | 'export' | 'on'; + export interface NewSessionConfig { model: string; mode?: SessionMode; @@ -25,6 +28,15 @@ export interface NewSessionConfig { systemPromptSections?: Record; modelCapabilities?: ModelCapabilitiesOverride; enableConfigDiscovery?: boolean; + /** "off" local only, "export" publish events to GitHub, "on" export + remote steering */ + remoteSession?: RemoteSessionMode; +} + +export interface CloudSessionConfig { + model?: string; + mode?: SessionMode; + reasoningEffort?: ReasoningEffort; + repository?: { owner: string; name: string; branch?: string }; } export interface PersistedSettings { @@ -49,7 +61,7 @@ export interface PersistedSettings { * - "on": full remote monitor + steer via github.com/Mobile. * The active client only honors this when ENABLE_REMOTE_SESSIONS is enabled server-side. */ - remoteSession?: 'off' | 'export' | 'on'; + remoteSession?: RemoteSessionMode; } export interface CustomAgentDefinition { diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 5687868..188dfea 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -11,6 +11,7 @@ import SettingsModal from '$lib/components/settings/SettingsModal.svelte'; import SessionsSheet from '$lib/components/sessions/SessionsSheet.svelte'; import TopBar from '$lib/components/layout/TopBar.svelte'; + import RemoteBanner from '$lib/components/layout/RemoteBanner.svelte'; import ModelSheet from '$lib/components/model/ModelSheet.svelte'; import { createWsStore } from '$lib/stores/ws.svelte.js'; import { createChatStore } from '$lib/stores/chat.svelte.js'; @@ -35,6 +36,8 @@ let modelSheetOpen = $state(false); let sessionsLoading = $state(false); let sessionLoading = $state(true); + let dismissedRemoteUrl = $state(null); + const showRemoteBanner = $derived(!!chatStore.remoteUrl && chatStore.remoteUrl !== dismissedRemoteUrl); // Use the confirmed model from the active session; fall back to the user's saved preference // so the TopBar/ModelSheet show the correct model immediately before session_created arrives. @@ -199,6 +202,7 @@ ...(isReasoning && { reasoningEffort: settings.reasoningEffort }), ...(settings.additionalInstructions.trim() && { customInstructions: settings.additionalInstructions.trim() }), ...(settings.excludedTools.length > 0 && { excludedTools: settings.excludedTools }), + ...(settings.remoteSession !== 'off' && { remoteSession: settings.remoteSession }), infiniteSessions: settings.infiniteSessions, }); } @@ -370,6 +374,14 @@ onOpenModelSheet={() => modelSheetOpen = true} /> + {#if showRemoteBanner && chatStore.remoteUrl} + { dismissedRemoteUrl = chatStore.remoteUrl; }} + /> + {/if} +
{#if sessionLoading}
@@ -533,6 +545,10 @@ onToggleMcpServer={(name, enabled) => wsStore.send({ type: 'toggle_mcp_rpc', name, enabled })} notificationsEnabled={settings.notificationsEnabled} onToggleNotifications={(v) => { settings.notificationsEnabled = v; }} + remoteSessionMode={settings.remoteSession} + onSetRemoteSessionMode={(mode) => { settings.remoteSession = mode; }} + remoteSessionActive={wsStore.sessionReady} + onApplyRemoteToSession={(mode) => wsStore.remoteToggle(mode)} voiceInputEnabled={settings.voiceInputEnabled} onToggleVoiceInput={(v) => { settings.voiceInputEnabled = v; }} ttsEnabled={settings.ttsEnabled} From e528c9cd6549c297dd1ca189d73acc916c212aa8 Mon Sep 17 00:00:00 2001 From: devartifex Date: Tue, 9 Jun 2026 23:37:28 +0200 Subject: [PATCH 04/14] test: settings sync includes remoteSession default Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/lib/stores/settings.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/lib/stores/settings.test.ts b/src/lib/stores/settings.test.ts index 4c8a807..e0f3143 100644 --- a/src/lib/stores/settings.test.ts +++ b/src/lib/stores/settings.test.ts @@ -155,6 +155,7 @@ describe('createSettingsStore', () => { voiceInputEnabled: true, ttsEnabled: true, ttsRate: 1, + remoteSession: 'off', }, }), }); From 5c4d61a28a7987afc371304811216f654b6a337a Mon Sep 17 00:00:00 2001 From: devartifex Date: Tue, 9 Jun 2026 23:38:46 +0200 Subject: [PATCH 05/14] feat: cloud session creation UI in SessionsSheet Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../components/sessions/SessionsSheet.svelte | 170 +++++++++++++++++- src/routes/+page.svelte | 8 + 2 files changed, 176 insertions(+), 2 deletions(-) diff --git a/src/lib/components/sessions/SessionsSheet.svelte b/src/lib/components/sessions/SessionsSheet.svelte index 087957f..32b2cdc 100644 --- a/src/lib/components/sessions/SessionsSheet.svelte +++ b/src/lib/components/sessions/SessionsSheet.svelte @@ -1,7 +1,7 @@