From f10fbc7496dc48fd78039f5de37f57fcd506ddcd Mon Sep 17 00:00:00 2001 From: SSU-WEI HUANG Date: Tue, 4 Aug 2026 08:19:12 +0800 Subject: [PATCH] feat(cli): add GitHub Copilot CLI agent support via ACP (#1245) * feat(cli): add GitHub Copilot CLI agent support via ACP Wrap `copilot --acp --stdio` for remote sessions and spawn the native TUI locally, with full hub/web integration for spawn, resume, and permissions. Fixes tiann/hapi#362 Co-Authored-By: HAPI Co-authored-by: Cursor * feat(copilot): agent modes, models, slash/file UX, local session sync Add Interactive/Plan/Autopilot (fleet is slash-only), subscription-aware model discovery, web StatusBar/permission UX, @ file mentions, and fix local Safe Yolo plus session-id locator for handoff/resume. Co-authored-by: Cursor * chore: re-trigger Codex PR review after auth outage Co-authored-by: Cursor * chore: retry Codex PR review Co-authored-by: Cursor * fix(copilot): preserve agent mode on resume and apply via ACP set_mode Resume was dropping copilotAgentMode so Plan/Autopilot reset to interactive. Also switch local/remote mode application to --mode / session set_mode instead of slash prompts. Co-authored-by: Cursor * fix(copilot): wake remote loop when agent mode changes Empty isolated queue tick lets setMode apply without inventing a user prompt. Co-authored-by: Cursor * fix(copilot): confirm mode changes before persisting Await Copilot mode changes and expose discovered models so session state reflects backend acceptance. Co-authored-by: Cursor * fix(copilot): guard mode discovery and slash updates Keep model probes within runner roots and preserve active sessions when mode switching is unavailable or rejected. Co-authored-by: Cursor * fix(copilot): preserve resume and auto semantics Deduplicate Copilot resume rows, apply Auto explicitly, and fail closed on denied permissions. Co-authored-by: Cursor * fix(copilot): close permission and model discovery gaps Keep write-capable commands pending in read-only mode, extend model probe RPCs, and preserve explicit model validation before session creation. Co-authored-by: Cursor * fix(copilot): persist runtime model and agent mode Fallback to ACP model options when direct model switching is unavailable and retain Copilot agent mode across hub restarts. Co-authored-by: Cursor * fix(copilot): normalize composer auto selection Use the null session sentinel for Copilot Auto so the composer selects and resets default models consistently. Co-authored-by: Cursor * fix(copilot): reject local permission mode changes * style(copilot): remove trailing blank line * fix(copilot): secure local config handoffs * fix(copilot): reject local agent mode slashes * fix(copilot): reject mode changes during turns * fix(copilot): consume rejected slash updates * fix(copilot): preserve thinking across slash handling * fix(copilot): stabilize async config changes * fix(copilot): roll back rejected startup model * fix(copilot): preserve cancellation and file mentions * fix(copilot): hide local permission controls * fix(deps): support clean workspace installs * test(copilot): account for spawn mode argument * fix(copilot): attribute usage to active model --------- Co-authored-by: HAPI Co-authored-by: Cursor --- bun.lock | 6 + cli/README.md | 2 +- cli/package.json | 1 + cli/src/agent/hapiSessionEnv.ts | 2 +- cli/src/agent/sessionFactory.ts | 1 + cli/src/api/apiMachine.test.ts | 66 +++ cli/src/api/apiMachine.ts | 23 +- cli/src/api/apiSession.ts | 1 + cli/src/commands/copilot.ts | 85 +++ cli/src/commands/registry.ts | 2 + cli/src/commands/resume.ts | 16 + cli/src/copilot/copilotLocal.test.ts | 22 + cli/src/copilot/copilotLocal.ts | 49 ++ cli/src/copilot/copilotLocalLauncher.ts | 75 +++ cli/src/copilot/copilotRemoteLauncher.test.ts | 151 ++++++ cli/src/copilot/copilotRemoteLauncher.ts | 485 ++++++++++++++++++ cli/src/copilot/loop.ts | 66 +++ cli/src/copilot/runCopilot.test.ts | 25 + cli/src/copilot/runCopilot.ts | 355 +++++++++++++ cli/src/copilot/session.ts | 117 +++++ cli/src/copilot/types.ts | 10 + cli/src/copilot/utils/config.ts | 10 + cli/src/copilot/utils/copilotBackend.test.ts | 26 + cli/src/copilot/utils/copilotBackend.ts | 29 ++ .../utils/copilotSessionLocator.test.ts | 137 +++++ .../copilot/utils/copilotSessionLocator.ts | 220 ++++++++ .../copilot/utils/permissionHandler.test.ts | 67 +++ cli/src/copilot/utils/permissionHandler.ts | 190 +++++++ cli/src/copilot/utils/slashCommands.test.ts | 67 +++ cli/src/copilot/utils/slashCommands.ts | 222 ++++++++ cli/src/modules/common/copilotModels.ts | 268 ++++++++++ .../modules/common/handlers/copilotModels.ts | 23 + .../modules/common/registerCommonHandlers.ts | 2 + cli/src/modules/common/rpcTypes.ts | 2 + cli/src/modules/common/skills.test.ts | 12 + cli/src/modules/common/skills.ts | 7 + cli/src/modules/common/slashCommands.test.ts | 27 + cli/src/runner/run.ts | 7 +- cli/src/ui/ink/CopilotDisplay.tsx | 196 +++++++ .../socket/handlers/cli/sessionHandlers.ts | 2 + hub/src/store/sessions.test.ts | 1 + hub/src/store/sessions.ts | 1 + hub/src/sync/opencodeClear.test.ts | 1 + hub/src/sync/rpcGateway.test.ts | 9 + hub/src/sync/rpcGateway.ts | 25 +- hub/src/sync/sessionCache.ts | 58 ++- hub/src/sync/sessionModel.test.ts | 126 +++++ hub/src/sync/syncEngine.ts | 30 +- hub/src/tunnel/tlsGate.ts | 5 +- hub/src/web/routes/guards.ts | 2 +- hub/src/web/routes/machines.ts | 28 +- hub/src/web/routes/sessions.ts | 53 ++ shared/src/apiTypes.ts | 24 +- shared/src/copilotModes.ts | 45 ++ shared/src/flavors.test.ts | 7 + shared/src/flavors.ts | 3 + shared/src/index.ts | 1 + shared/src/modes.ts | 8 +- shared/src/resume.ts | 5 +- shared/src/rpcMethods.ts | 2 + shared/src/schemas.ts | 12 +- shared/src/sessionSummary.ts | 2 + shared/src/slashCommands.ts | 22 + shared/src/socket.ts | 2 + shared/src/types.ts | 2 + web/package.json | 5 + web/src/api/client.ts | 27 +- web/src/components/AgentFlavorIcon.test.tsx | 7 + web/src/components/AgentFlavorIcon.tsx | 12 + .../AssistantChat/HappyComposer.tsx | 73 ++- .../components/AssistantChat/StatusBar.tsx | 20 +- .../AssistantChat/modelOptions.test.ts | 20 + .../components/AssistantChat/modelOptions.ts | 9 + ...CodexFamilyPermissionModeSelector.test.tsx | 47 ++ .../CodexFamilyPermissionModeSelector.tsx | 39 ++ .../NewSession/CopilotAgentModeSelector.tsx | 36 ++ .../components/NewSession/ModelSelector.tsx | 5 +- web/src/components/NewSession/index.test.tsx | 81 ++- web/src/components/NewSession/index.tsx | 100 +++- .../NewSession/newSessionFormDraft.test.ts | 31 ++ .../NewSession/newSessionFormDraft.ts | 22 +- web/src/components/NewSession/preferences.ts | 2 +- web/src/components/NewSession/types.ts | 3 + web/src/components/SessionChat.tsx | 50 +- .../components/ToolCard/PermissionFooter.tsx | 1 + web/src/components/icons/CopilotIcon.tsx | 20 + web/src/hooks/mutations/useSessionActions.ts | 18 +- web/src/hooks/mutations/useSpawnSession.ts | 6 +- web/src/hooks/queries/useCopilotModels.ts | 38 ++ .../hooks/queries/useCopilotModelsForCwd.ts | 48 ++ web/src/lib/codexFamilyPermissionAgents.ts | 19 + web/src/lib/locales/en.ts | 2 + web/src/lib/locales/zh-CN.ts | 2 + web/src/lib/query-keys.ts | 2 + web/src/lib/sessionResume.test.ts | 6 + web/src/lib/sessionResume.ts | 1 + web/src/router.tsx | 7 +- web/src/types/api.ts | 4 + 98 files changed, 4255 insertions(+), 56 deletions(-) create mode 100644 cli/src/commands/copilot.ts create mode 100644 cli/src/copilot/copilotLocal.test.ts create mode 100644 cli/src/copilot/copilotLocal.ts create mode 100644 cli/src/copilot/copilotLocalLauncher.ts create mode 100644 cli/src/copilot/copilotRemoteLauncher.test.ts create mode 100644 cli/src/copilot/copilotRemoteLauncher.ts create mode 100644 cli/src/copilot/loop.ts create mode 100644 cli/src/copilot/runCopilot.test.ts create mode 100644 cli/src/copilot/runCopilot.ts create mode 100644 cli/src/copilot/session.ts create mode 100644 cli/src/copilot/types.ts create mode 100644 cli/src/copilot/utils/config.ts create mode 100644 cli/src/copilot/utils/copilotBackend.test.ts create mode 100644 cli/src/copilot/utils/copilotBackend.ts create mode 100644 cli/src/copilot/utils/copilotSessionLocator.test.ts create mode 100644 cli/src/copilot/utils/copilotSessionLocator.ts create mode 100644 cli/src/copilot/utils/permissionHandler.test.ts create mode 100644 cli/src/copilot/utils/permissionHandler.ts create mode 100644 cli/src/copilot/utils/slashCommands.test.ts create mode 100644 cli/src/copilot/utils/slashCommands.ts create mode 100644 cli/src/modules/common/copilotModels.ts create mode 100644 cli/src/modules/common/handlers/copilotModels.ts create mode 100644 cli/src/ui/ink/CopilotDisplay.tsx create mode 100644 shared/src/copilotModes.ts create mode 100644 web/src/components/NewSession/CodexFamilyPermissionModeSelector.test.tsx create mode 100644 web/src/components/NewSession/CodexFamilyPermissionModeSelector.tsx create mode 100644 web/src/components/NewSession/CopilotAgentModeSelector.tsx create mode 100644 web/src/components/icons/CopilotIcon.tsx create mode 100644 web/src/hooks/queries/useCopilotModels.ts create mode 100644 web/src/hooks/queries/useCopilotModelsForCwd.ts create mode 100644 web/src/lib/codexFamilyPermissionAgents.ts diff --git a/bun.lock b/bun.lock index 9338b7dd..9e50ae3a 100644 --- a/bun.lock +++ b/bun.lock @@ -35,6 +35,7 @@ "react": "^19.2.3", "socket.io-client": "^4.8.3", "tar": "^7.5.2", + "vscode-jsonrpc": "8.2.0", "yaml": "^2.8.2", "zod": "^4.2.1", }, @@ -122,6 +123,7 @@ "qrcode": "^1.5.4", "react": "^19.2.3", "react-dom": "^19.2.3", + "react-markdown": "^10.1.0", "rehype-katex": "^7.0.1", "remark-breaks": "^4.0.0", "remark-gfm": "^4.0.1", @@ -140,6 +142,8 @@ "@tailwindcss/postcss": "^4.1.18", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.0", + "@types/hast": "^3.0.4", + "@types/mdast": "^4.0.4", "@types/qrcode": "^1.5.6", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", @@ -150,9 +154,11 @@ "postcss": "^8.5.6", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", + "remark-stringify": "^11.0.0", "tailwindcss": "^4.1.18", "typescript": "^5.9.3", "unified": "^11.0.5", + "vfile": "^6.0.3", "vite": "^7.3.0", "vitest": "^4.0.16", }, diff --git a/cli/README.md b/cli/README.md index 983634d3..ac56d2c4 100644 --- a/cli/README.md +++ b/cli/README.md @@ -118,7 +118,7 @@ See `src/configuration.ts` for all options. ### Set for the wrapped agent -- `HAPI_SESSION_ID` - The hub session id for the current run, exported into the wrapped agent/CLI child environment at spawn for every flavor (claude / codex / cursor / gemini / opencode / kimi / grok / pi), both runner-spawned and locally started sessions. Agents can read it to self-target "this chat" over the hub REST API or shell helpers without listing `/api/sessions`. Prefer the MCP `display_image` tool for inline media when it is available; use `HAPI_SESSION_ID` for hub REST / shell tooling where MCP is not. To **read** another session, prefer MCP `inspect_peer` or `hapi inspect-peer`. To **message** another session, prefer MCP `ping_peer` or `hapi ping-peer` — do not reinvent JWT+curl. User citations look like `[title](/sessions/)`; pass that `` as `sessionIdPrefix`. +- `HAPI_SESSION_ID` - The hub session id for the current run, exported into the wrapped agent/CLI child environment at spawn for every flavor (claude / codex / copilot / cursor / gemini / opencode / kimi / grok / pi), both runner-spawned and locally started sessions. Agents can read it to self-target "this chat" over the hub REST API or shell helpers without listing `/api/sessions`. Prefer the MCP `display_image` tool for inline media when it is available; use `HAPI_SESSION_ID` for hub REST / shell tooling where MCP is not. To **read** another session, prefer MCP `inspect_peer` or `hapi inspect-peer`. To **message** another session, prefer MCP `ping_peer` or `hapi ping-peer` — do not reinvent JWT+curl. User citations look like `[title](/sessions/)`; pass that `` as `sessionIdPrefix`. Lazy Codex (terminal) sessions export the id only after the hub row is materialized, which happens when the MCP bridge starts — before the agent process is spawned — so path-only self-targeting does not race a missing hub row. diff --git a/cli/package.json b/cli/package.json index c9996c61..07ee01e8 100644 --- a/cli/package.json +++ b/cli/package.json @@ -67,6 +67,7 @@ "react": "^19.2.3", "socket.io-client": "^4.8.3", "tar": "^7.5.2", + "vscode-jsonrpc": "8.2.0", "yaml": "^2.8.2", "zod": "^4.2.1" }, diff --git a/cli/src/agent/hapiSessionEnv.ts b/cli/src/agent/hapiSessionEnv.ts index 2336a6bd..935757df 100644 --- a/cli/src/agent/hapiSessionEnv.ts +++ b/cli/src/agent/hapiSessionEnv.ts @@ -10,7 +10,7 @@ export const HAPI_SESSION_ID_ENV = 'HAPI_SESSION_ID'; * inherits it. HAPI runs one hub session per CLI process (the runner forks a * fresh `hapi` child per session, and local invocations are 1:1), and every * flavor's agent spawn derives its child env from `process.env` — so setting it - * here covers claude / codex / cursor / gemini / opencode / kimi / grok / pi at + * here covers claude / codex / copilot / cursor / gemini / opencode / kimi / grok / pi at * once, including future flavors, without touching each launcher. * * Prefer the MCP `display_image` tool for inline media when it is available; diff --git a/cli/src/agent/sessionFactory.ts b/cli/src/agent/sessionFactory.ts index 93fe9717..80c374ad 100644 --- a/cli/src/agent/sessionFactory.ts +++ b/cli/src/agent/sessionFactory.ts @@ -105,6 +105,7 @@ function pickExistingSessionMetadata(metadata: Metadata | null | undefined): Par if (metadata.cursorSessionId !== undefined) preserved.cursorSessionId = metadata.cursorSessionId if (metadata.cursorSessionProtocol !== undefined) preserved.cursorSessionProtocol = metadata.cursorSessionProtocol if (metadata.kimiSessionId !== undefined) preserved.kimiSessionId = metadata.kimiSessionId + if (metadata.copilotSessionId !== undefined) preserved.copilotSessionId = metadata.copilotSessionId if (metadata.piSessionId !== undefined) preserved.piSessionId = metadata.piSessionId if (metadata.piResumeAttempt !== undefined) preserved.piResumeAttempt = metadata.piResumeAttempt if (metadata.preferredPermissionMode !== undefined) preserved.preferredPermissionMode = metadata.preferredPermissionMode diff --git a/cli/src/api/apiMachine.test.ts b/cli/src/api/apiMachine.test.ts index 2c838417..6a6e1ea6 100644 --- a/cli/src/api/apiMachine.test.ts +++ b/cli/src/api/apiMachine.test.ts @@ -6,6 +6,7 @@ import { join } from 'node:path' const ioMock = vi.hoisted(() => vi.fn()) const listOpencodeModelsForCwdMock = vi.hoisted(() => vi.fn()) const listGrokModelsForCwdMock = vi.hoisted(() => vi.fn()) +const listCopilotModelsForCwdMock = vi.hoisted(() => vi.fn()) const inspectCursorChatStoreMock = vi.hoisted(() => vi.fn()) vi.mock('socket.io-client', () => ({ @@ -24,6 +25,10 @@ vi.mock('../modules/common/grokModels', () => ({ listGrokModelsForCwd: listGrokModelsForCwdMock })) +vi.mock('../modules/common/copilotModels', () => ({ + listCopilotModelsForCwd: listCopilotModelsForCwdMock +})) + vi.mock('@/cursor/cursorChatStoreStatus', () => ({ inspectCursorChatStore: inspectCursorChatStoreMock })) @@ -79,6 +84,15 @@ async function callListGrokModels(client: ApiMachineClient, machineId: string, c return JSON.parse(raw) as unknown } +async function callListCopilotModels(client: ApiMachineClient, machineId: string, cwd: string): Promise { + const manager = (client as unknown as { rpcHandlerManager: { handleRequest: (req: { method: string; params: string }) => Promise } }).rpcHandlerManager + const raw = await manager.handleRequest({ + method: `${machineId}:listCopilotModelsForCwd`, + params: JSON.stringify({ cwd }) + }) + return JSON.parse(raw) as unknown +} + async function callCursorChatStoreStatus( client: ApiMachineClient, machineId: string, @@ -277,6 +291,58 @@ describe('ApiMachineClient listOpencodeModelsForCwd handler', () => { }) }) +describe('ApiMachineClient listCopilotModelsForCwd handler', () => { + let workspaceRoot: string + + beforeEach(() => { + ioMock.mockReset() + listCopilotModelsForCwdMock.mockReset() + workspaceRoot = mkdtempSync(join(tmpdir(), 'hapi-copilot-machine-ws-')) + }) + + afterEach(() => { + rmSync(workspaceRoot, { recursive: true, force: true }) + }) + + it('rejects cwd outside workspace roots before running the Copilot model probe', async () => { + const machine = makeMachine('copilot-machine-1') + const client = new ApiMachineClient('cli-token', machine, [workspaceRoot]) + const outsideCwd = mkdtempSync(join(tmpdir(), 'hapi-copilot-outside-')) + + try { + expect(await callListCopilotModels(client, machine.id, outsideCwd)).toEqual({ + success: false, + error: 'Path is outside workspace roots' + }) + expect(listCopilotModelsForCwdMock).not.toHaveBeenCalled() + } finally { + rmSync(outsideCwd, { recursive: true, force: true }) + client.shutdown() + } + }) + + it('forwards a resolved workspace cwd to the Copilot model probe', async () => { + const machine = makeMachine('copilot-machine-2') + const client = new ApiMachineClient('cli-token', machine, [workspaceRoot]) + listCopilotModelsForCwdMock.mockResolvedValueOnce({ + success: true, + availableModels: [{ modelId: 'gpt-5.6' }], + currentModelId: 'gpt-5.6' + }) + + try { + expect(await callListCopilotModels(client, machine.id, workspaceRoot)).toEqual({ + success: true, + availableModels: [{ modelId: 'gpt-5.6' }], + currentModelId: 'gpt-5.6' + }) + expect(listCopilotModelsForCwdMock).toHaveBeenCalledWith(realpathSync.native(workspaceRoot)) + } finally { + client.shutdown() + } + }) +}) + describe('ApiMachineClient listGrokModelsForCwd handler', () => { let workspaceRoot: string diff --git a/cli/src/api/apiMachine.ts b/cli/src/api/apiMachine.ts index c704a9ab..95b7409d 100644 --- a/cli/src/api/apiMachine.ts +++ b/cli/src/api/apiMachine.ts @@ -35,6 +35,11 @@ import { type ListGrokModelsForCwdRequest, type ListGrokModelsForCwdResponse } from '../modules/common/grokModels' +import { + listCopilotModelsForCwd, + type ListCopilotModelsForCwdRequest, + type ListCopilotModelsForCwdResponse +} from '../modules/common/copilotModels' import type { SpawnSessionOptions, SpawnSessionResult } from '../modules/common/rpcTypes' import { applyVersionedAck } from './versionedUpdate' import { archiveLocalCodexSession, listLocalCodexSessionSummaries, listLocalCodexSessionsWithMessagesByIds } from '../modules/common/codexSessions' @@ -270,6 +275,21 @@ export class ApiMachineClient { } ) + this.rpcHandlerManager.registerHandler( + RPC_METHODS.ListCopilotModelsForCwd, + async (params) => { + const rawCwd = typeof params?.cwd === 'string' ? params.cwd.trim() : '' + if (!rawCwd) return { success: false, error: 'cwd is required' } + + const resolvedCwd = await this.resolveForWorkspaceCheck(rawCwd) + if (!this.isWithinWorkspaceRoots(resolvedCwd)) { + return { success: false, error: 'Path is outside workspace roots' } + } + + return await listCopilotModelsForCwd(resolvedCwd) + } + ) + this.rpcHandlerManager.registerHandler( RPC_METHODS.ListCodexSessions, async (params) => { @@ -360,7 +380,7 @@ export class ApiMachineClient { setRPCHandlers({ spawnSession, stopSession, requestShutdown }: MachineRpcHandlers): void { this.rpcHandlerManager.registerHandler(RPC_METHODS.SpawnHappySession, async (params: any) => { - const { directory, sessionId, existingSessionId, resumeSessionId, machineId, approvedNewDirectoryCreation, agent, model, effort, modelReasoningEffort, yolo, permissionMode, serviceTier, collaborationMode, token, sessionType, worktreeName, forkSession } = params || {} + const { directory, sessionId, existingSessionId, resumeSessionId, machineId, approvedNewDirectoryCreation, agent, model, effort, modelReasoningEffort, yolo, permissionMode, serviceTier, collaborationMode, copilotAgentMode, token, sessionType, worktreeName, forkSession } = params || {} if (!directory) { throw new Error('Directory is required') @@ -386,6 +406,7 @@ export class ApiMachineClient { permissionMode, serviceTier, collaborationMode, + copilotAgentMode, token, sessionType, worktreeName, diff --git a/cli/src/api/apiSession.ts b/cli/src/api/apiSession.ts index 78af87c2..db654325 100644 --- a/cli/src/api/apiSession.ts +++ b/cli/src/api/apiSession.ts @@ -859,6 +859,7 @@ export class ApiSessionClient extends EventEmitter { effort?: string | null serviceTier?: string | null collaborationMode?: SessionCollaborationMode + copilotAgentMode?: import('@hapi/protocol').CopilotAgentMode } ): void { if (this.state !== 'active') { diff --git a/cli/src/commands/copilot.ts b/cli/src/commands/copilot.ts new file mode 100644 index 00000000..28faacd4 --- /dev/null +++ b/cli/src/commands/copilot.ts @@ -0,0 +1,85 @@ +import chalk from 'chalk' +import { authAndSetupMachineIfNeeded } from '@/ui/auth' +import { initializeToken } from '@/ui/tokenInit' +import { maybeAutoStartServer } from '@/utils/autoStartServer' +import type { CommandDefinition } from './types' +import { COPILOT_PERMISSION_MODES } from '@hapi/protocol/modes' +import type { CopilotPermissionMode } from '@hapi/protocol/types' +import { isCopilotAgentMode, type CopilotAgentMode } from '@hapi/protocol' + +export const copilotCommand: CommandDefinition = { + name: 'copilot', + requiresRuntimeAssets: true, + run: async ({ commandArgs }) => { + try { + const options: { + startedBy?: 'runner' | 'terminal' + startingMode?: 'local' | 'remote' + permissionMode?: CopilotPermissionMode + model?: string + copilotAgentMode?: CopilotAgentMode + resumeSessionId?: string + } = {} + + let hasExplicitPermissionMode = false + + for (let i = 0; i < commandArgs.length; i++) { + const arg = commandArgs[i] + if (arg === '--started-by') { + options.startedBy = commandArgs[++i] as 'runner' | 'terminal' + } else if (arg === '--hapi-starting-mode') { + const value = commandArgs[++i] + if (value === 'local' || value === 'remote') { + options.startingMode = value + } else { + throw new Error('Invalid --hapi-starting-mode (expected local or remote)') + } + } else if (arg === '--permission-mode') { + const mode = commandArgs[++i] + if (!mode || !(COPILOT_PERMISSION_MODES as readonly string[]).includes(mode)) { + throw new Error(`Invalid --permission-mode value: ${mode ?? '(missing)'}`) + } + options.permissionMode = mode as CopilotPermissionMode + hasExplicitPermissionMode = true + } else if (arg === '--yolo' && !hasExplicitPermissionMode) { + options.permissionMode = 'yolo' + } else if (arg === '--resume') { + const sessionId = commandArgs[++i] + if (!sessionId) { + throw new Error('Missing --resume value') + } + options.resumeSessionId = sessionId + } else if (arg === '--model') { + const model = commandArgs[++i] + if (!model) { + throw new Error('Missing --model value') + } + options.model = model + } else if (arg === '--copilot-agent-mode' || arg === '--mode') { + const mode = commandArgs[++i] + if (!mode || !isCopilotAgentMode(mode)) { + throw new Error( + mode === 'fleet' + ? 'Fleet is not an agent mode; use /fleet inside the session (with Interactive, Plan, or Autopilot)' + : `Invalid --copilot-agent-mode value: ${mode ?? '(missing)'} (expected interactive, plan, or autopilot)` + ) + } + options.copilotAgentMode = mode + } + } + + await initializeToken() + await maybeAutoStartServer() + await authAndSetupMachineIfNeeded() + + const { runCopilot } = await import('@/copilot/runCopilot') + await runCopilot(options) + } catch (error) { + console.error(chalk.red('Error:'), error instanceof Error ? error.message : 'Unknown error') + if (process.env.DEBUG) { + console.error(error) + } + process.exit(1) + } + } +} diff --git a/cli/src/commands/registry.ts b/cli/src/commands/registry.ts index a21c38b2..16481f1c 100644 --- a/cli/src/commands/registry.ts +++ b/cli/src/commands/registry.ts @@ -8,6 +8,7 @@ import { runnerCommand } from './runner' import { resumeCommand } from './resume' import { doctorCommand } from './doctor' import { kimiCommand } from './kimi' +import { copilotCommand } from './copilot' import { grokCommand } from './grok' import { opencodeCommand } from './opencode' import { piCommand } from './pi' @@ -43,6 +44,7 @@ const COMMANDS: CommandDefinition[] = [ removedGeminiCommand, grokCommand, kimiCommand, + copilotCommand, opencodeCommand, piCommand, mcpCommand, diff --git a/cli/src/commands/resume.ts b/cli/src/commands/resume.ts index 50b18223..4b4aface 100644 --- a/cli/src/commands/resume.ts +++ b/cli/src/commands/resume.ts @@ -9,6 +9,7 @@ import type { CursorPermissionMode, GrokPermissionMode, KimiPermissionMode, + CopilotPermissionMode, OpencodePermissionMode } from '@hapi/protocol/types' import { ApiClient } from '@/api/api' @@ -150,6 +151,21 @@ async function dispatchLocalResume(target: LocalResumeTarget): Promise { return } + if (target.flavor === 'copilot') { + const { runCopilot } = await import('@/copilot/runCopilot') + await runCopilot({ + existingSessionId: base.existingSessionId, + workingDirectory: base.workingDirectory, + resumeSessionId: base.resumeSessionId, + startedBy: base.startedBy, + permissionMode: base.permissionMode as CopilotPermissionMode | undefined, + startingMode: 'local', + model: target.model ?? undefined, + copilotAgentMode: target.copilotAgentMode + }) + return + } + if (target.flavor === 'pi') { const { runPi } = await import('@/pi/runPi') await runPi({ diff --git a/cli/src/copilot/copilotLocal.test.ts b/cli/src/copilot/copilotLocal.test.ts new file mode 100644 index 00000000..01a6bfd9 --- /dev/null +++ b/cli/src/copilot/copilotLocal.test.ts @@ -0,0 +1,22 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { buildCopilotLocalArgs } from './copilotLocal'; + +const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform'); + +afterEach(() => { + if (originalPlatformDescriptor) Object.defineProperty(process, 'platform', originalPlatformDescriptor); +}); + +describe('buildCopilotLocalArgs', () => { + it('builds resume, model, approval, and mode arguments', () => { + expect(buildCopilotLocalArgs({ sessionId: 'session-1', model: 'gpt-5', yolo: true, agentMode: 'plan' })) + .toEqual(['--resume=session-1', '--model', 'gpt-5', '--allow-all', '--mode', 'plan']); + }); + + it('rejects shell metacharacters in dynamic Windows arguments', () => { + Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }); + + expect(() => buildCopilotLocalArgs({ sessionId: 'session&whoami' })).toThrow('Invalid sessionId'); + expect(() => buildCopilotLocalArgs({ sessionId: 'session-1', model: 'gpt|whoami' })).toThrow('Invalid model'); + }); +}); diff --git a/cli/src/copilot/copilotLocal.ts b/cli/src/copilot/copilotLocal.ts new file mode 100644 index 00000000..3ca9778c --- /dev/null +++ b/cli/src/copilot/copilotLocal.ts @@ -0,0 +1,49 @@ +import { logger } from '@/ui/logger'; +import { spawnWithTerminalGuard } from '@/utils/spawnWithTerminalGuard'; + +import type { CopilotAgentMode } from '@hapi/protocol'; +import { assertSafeWindowsShellArg } from '@/grok/utils/windowsShellArgs'; + +export function buildCopilotLocalArgs(opts: { + sessionId: string | null; + model?: string; + yolo?: boolean; + agentMode?: CopilotAgentMode; +}): string[] { + if (opts.sessionId) assertSafeWindowsShellArg(opts.sessionId, 'sessionId'); + if (opts.model) assertSafeWindowsShellArg(opts.model, 'model'); + + const args: string[] = []; + if (opts.sessionId) args.push(`--resume=${opts.sessionId}`); + if (opts.model) args.push('--model', opts.model); + if (opts.yolo) args.push('--allow-all'); + if (opts.agentMode && opts.agentMode !== 'interactive') args.push('--mode', opts.agentMode); + return args; +} + +export async function copilotLocal(opts: { + path: string; + sessionId: string | null; + abort: AbortSignal; + model?: string; + yolo?: boolean; + agentMode?: CopilotAgentMode; +}): Promise { + const args = buildCopilotLocalArgs(opts); + + logger.debug(`[CopilotLocal] Spawning copilot with args: ${JSON.stringify(args)}`); + + await spawnWithTerminalGuard({ + command: process.env.COPILOT_CLI_PATH ?? 'copilot', + args, + cwd: opts.path, + env: process.env, + signal: opts.abort, + shell: process.platform === 'win32', + logLabel: 'CopilotLocal', + spawnName: 'copilot', + installHint: 'GitHub Copilot CLI (npm install -g @github/copilot)', + includeCause: true, + logExit: true + }); +} diff --git a/cli/src/copilot/copilotLocalLauncher.ts b/cli/src/copilot/copilotLocalLauncher.ts new file mode 100644 index 00000000..6491eb96 --- /dev/null +++ b/cli/src/copilot/copilotLocalLauncher.ts @@ -0,0 +1,75 @@ +import { BaseLocalLauncher } from '@/modules/common/launcher/BaseLocalLauncher'; +import { logger } from '@/ui/logger'; +import { copilotLocal } from './copilotLocal'; +import type { CopilotSession } from './session'; +import type { PermissionMode } from './types'; +import { createCopilotSessionLocator } from './utils/copilotSessionLocator'; + +/** Only full `yolo` maps to `--allow-all`. `safe-yolo` stays interactive/local defaults. */ +export function mapCopilotLocalApprovalMode(mode: PermissionMode | undefined): { yolo: boolean } { + if (mode === 'yolo') { + return { yolo: true }; + } + return { yolo: false }; +} + +export async function copilotLocalLauncher( + session: CopilotSession, + opts: { + model?: string; + } +): Promise<'switch' | 'exit'> { + const startupTimestampMs = Date.now(); + let shuttingDown = false; + + const locator = createCopilotSessionLocator({ + cwd: session.path, + startupTimestampMs, + resumeSessionId: session.sessionId, + onLocated: ({ sessionId }) => { + if (shuttingDown) { + return; + } + session.onSessionFound(sessionId); + }, + onAmbiguous: (sessionIds) => { + logger.warn( + `[copilot-local]: Multiple fresh Copilot sessions found (${sessionIds.join(', ')}); session id sync disabled for this launch` + ); + } + }); + + const launcher = new BaseLocalLauncher({ + label: 'copilot-local', + failureLabel: 'Local Copilot process failed', + queue: session.queue, + rpcHandlerManager: session.client.rpcHandlerManager, + startedBy: session.startedBy, + startingMode: session.startingMode, + launch: async (abortSignal) => { + await locator.ready; + const approval = mapCopilotLocalApprovalMode(session.getPermissionMode() as PermissionMode | undefined); + await copilotLocal({ + path: session.path, + sessionId: session.sessionId, + abort: abortSignal, + model: opts.model, + yolo: approval.yolo, + agentMode: session.getAgentMode() + }); + }, + sendFailureMessage: (message) => { + session.sendSessionEvent({ type: 'message', message }); + }, + recordLocalLaunchFailure: (message, exitReason) => { + session.recordLocalLaunchFailure(message, exitReason); + } + }); + + try { + return await launcher.run(); + } finally { + shuttingDown = true; + await locator.cleanup(); + } +} diff --git a/cli/src/copilot/copilotRemoteLauncher.test.ts b/cli/src/copilot/copilotRemoteLauncher.test.ts new file mode 100644 index 00000000..79ac6771 --- /dev/null +++ b/cli/src/copilot/copilotRemoteLauncher.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { CopilotSession } from './session'; +import { CopilotRemoteLauncher } from './copilotRemoteLauncher'; +import type { AgentMessage } from '@/agent/types'; + +type LauncherInternals = { + backend: { + setMode: (sessionId: string, mode: string) => Promise; + setModel?: (sessionId: string, model: string) => Promise; + setConfigOption?: (sessionId: string, configId: string, value: string) => Promise; + getConfigOptionByCategory?: (sessionId: string, category: string) => { + id: string; + options: Array<{ value: string }>; + } | undefined; + } | null; + activeSessionId: string | null; + currentAgentMode: string; + displayAgentMode: string | null; + applyInitialAgentMode: () => Promise; + currentBackendModel: string | null; + applyQueuedModel: (model: string) => Promise; + handleAgentMessage: (message: AgentMessage) => void; +}; + +function createLauncher( + setMode: (sessionId: string, mode: string) => Promise, + onModelRollback?: (model: string | null) => void +) { + const session = { + sendSessionEvent: vi.fn(), + sendAgentMessage: vi.fn(), + setModel: vi.fn(), + pushKeepAlive: vi.fn() + } as unknown as CopilotSession; + const launcher = new CopilotRemoteLauncher(session, { onModelRollback }); + const internals = launcher as unknown as LauncherInternals; + internals.backend = { setMode }; + internals.activeSessionId = 'copilot-session'; + return { launcher, internals, session }; +} + +describe('CopilotRemoteLauncher.applyAgentMode', () => { + it('attributes usage to the active Copilot model', () => { + const { internals, session } = createLauncher(vi.fn().mockResolvedValue(undefined)); + internals.currentBackendModel = 'gpt-5.6'; + + internals.handleAgentMessage({ + type: 'usage', + inputTokens: 10, + outputTokens: 2, + totalTokens: 12 + }); + + expect(session.sendAgentMessage).toHaveBeenCalledWith(expect.objectContaining({ + type: 'token_count', + model: 'gpt-5.6' + })); + }); + + it('does not update the acknowledged or displayed mode when setMode fails', async () => { + const setMode = vi.fn().mockRejectedValue(new Error('transport unavailable')); + const { launcher, internals, session } = createLauncher(setMode); + + await expect(launcher.applyAgentMode('plan')).rejects.toThrow('transport unavailable'); + + expect(internals.currentAgentMode).toBe('interactive'); + expect(internals.displayAgentMode).toBeNull(); + expect(session.sendSessionEvent).toHaveBeenCalledWith({ + type: 'message', + message: expect.stringContaining('Failed to switch Copilot agent mode') + }); + }); + + it('rejects later changes after Copilot reports mode switching unsupported', async () => { + const setMode = vi.fn().mockRejectedValue(new Error('Method not found')); + const { launcher, internals } = createLauncher(setMode); + + await expect(launcher.applyAgentMode('plan')).rejects.toThrow('Method not found'); + await expect(launcher.applyAgentMode('autopilot')).rejects.toThrow( + 'does not support agent mode switching' + ); + + expect(setMode).toHaveBeenCalledTimes(1); + expect(internals.currentAgentMode).toBe('interactive'); + }); + + it('continues startup when runtime mode switching is unsupported', async () => { + const setMode = vi.fn().mockRejectedValue(new Error('Method not found')); + const { internals } = createLauncher(setMode); + + await expect(internals.applyInitialAgentMode()).resolves.toBeUndefined(); + + expect(internals.currentAgentMode).toBe('interactive'); + expect(internals.displayAgentMode).toBe('interactive'); + }); + + it('applies Auto after an explicit model selection', async () => { + const setModel = vi.fn().mockResolvedValue(undefined); + const { internals } = createLauncher(vi.fn().mockResolvedValue(undefined)); + internals.backend = { + setMode: vi.fn().mockResolvedValue(undefined), + setModel + }; + internals.currentBackendModel = 'gpt-5.6'; + + await expect(internals.applyQueuedModel('auto')).resolves.toBe('auto'); + + expect(setModel).toHaveBeenCalledWith('copilot-session', 'auto'); + expect(internals.currentBackendModel).toBe('auto'); + }); + + it('rolls back the published model when switching fails', async () => { + const onModelRollback = vi.fn(); + const { internals, session } = createLauncher( + vi.fn().mockResolvedValue(undefined), + onModelRollback + ); + internals.backend = { + setMode: vi.fn().mockResolvedValue(undefined), + setModel: vi.fn().mockRejectedValue(new Error('transport unavailable')) + }; + internals.currentBackendModel = 'gpt-5.4'; + + await expect(internals.applyQueuedModel('gpt-5.6')).resolves.toBe('gpt-5.4'); + + expect(session.setModel).toHaveBeenCalledWith('gpt-5.4'); + expect(session.pushKeepAlive).toHaveBeenCalledOnce(); + expect(onModelRollback).toHaveBeenCalledWith('gpt-5.4'); + }); + + it('falls back to the model config option when setModel is unavailable', async () => { + const setModel = vi.fn().mockRejectedValue(new Error('Method not found')); + const setConfigOption = vi.fn().mockResolvedValue(undefined); + const { internals } = createLauncher(vi.fn().mockResolvedValue(undefined)); + internals.backend = { + setMode: vi.fn().mockResolvedValue(undefined), + setModel, + setConfigOption, + getConfigOptionByCategory: vi.fn().mockReturnValue({ + id: 'model', + options: [{ value: 'gpt-5.6' }] + }) + }; + internals.currentBackendModel = 'gpt-5.4'; + + await expect(internals.applyQueuedModel('gpt-5.6')).resolves.toBe('gpt-5.6'); + + expect(setConfigOption).toHaveBeenCalledWith('copilot-session', 'model', 'gpt-5.6'); + expect(internals.currentBackendModel).toBe('gpt-5.6'); + }); +}); diff --git a/cli/src/copilot/copilotRemoteLauncher.ts b/cli/src/copilot/copilotRemoteLauncher.ts new file mode 100644 index 00000000..8b06ecbe --- /dev/null +++ b/cli/src/copilot/copilotRemoteLauncher.ts @@ -0,0 +1,485 @@ +import React from 'react'; +import { registerAcpSessionTitleSync } from '@/agent/acpSessionTitle'; +import { logger } from '@/ui/logger'; +import { buildHapiMcpBridge } from '@/codex/utils/buildHapiMcpBridge'; +import { convertAgentMessage } from '@/agent/messageConverter'; +import type { AgentMessage, McpServerStdio, PromptContent } from '@/agent/types'; +import { RemoteLauncherBase, type RemoteLauncherDisplayContext, type RemoteLauncherExitReason } from '@/modules/common/remote/RemoteLauncherBase'; +import { CopilotDisplay } from '@/ui/ink/CopilotDisplay'; +import type { CopilotSession } from './session'; +import type { PermissionMode } from './types'; +import { createCopilotBackend } from './utils/copilotBackend'; +import { CopilotPermissionHandler } from './utils/permissionHandler'; +import { resolveCopilotRuntimeConfig } from './utils/config'; +import { getCopilotAgentModeLabel, type CopilotAgentMode } from '@hapi/protocol'; +import { RPC_METHODS } from '@hapi/protocol/rpcMethods'; +import { buildCopilotModelsResponseFromBackend } from '@/modules/common/copilotModels'; + +export class CopilotRemoteLauncher extends RemoteLauncherBase { + private readonly session: CopilotSession; + private readonly model?: string; + private readonly onModelRollback?: (model: string | null) => void; + private backend: ReturnType | null = null; + private permissionHandler: CopilotPermissionHandler | null = null; + private happyServer: { stop: () => void } | null = null; + private abortController = new AbortController(); + private displayModel: string | null = null; + private displayPermissionMode: PermissionMode | null = null; + private displayAgentMode: CopilotAgentMode | null = null; + private currentAgentMode: CopilotAgentMode = 'interactive'; + private currentBackendModel: string | null = null; + private setModelSupported: boolean | undefined = undefined; + private setModeSupported: boolean | undefined = undefined; + private activeSessionId: string | null = null; + private readonly lastDisplayedToolCall = new Map(); + + constructor(session: CopilotSession, opts: { + model?: string; + onModelRollback?: (model: string | null) => void; + }) { + super(process.env.DEBUG ? session.logPath : undefined); + this.session = session; + this.model = opts.model; + this.onModelRollback = opts.onModelRollback; + } + + public async launch(): Promise { + return this.start({ + onExit: () => this.handleExitFromUi(), + onSwitchToLocal: () => this.handleSwitchFromUi() + }); + } + + protected createDisplay(context: RemoteLauncherDisplayContext): React.ReactElement { + return React.createElement(CopilotDisplay, context); + } + + protected async runMainLoop(): Promise { + const session = this.session; + const messageBuffer = this.messageBuffer; + + const { server: happyServer, mcpServers } = await buildHapiMcpBridge(session.client, { + enableChangeTitle: false, + skillLookup: { workingDirectory: session.path, flavor: 'copilot' } + }); + this.happyServer = happyServer; + + const runtimeConfig = resolveCopilotRuntimeConfig({ model: this.model }); + + this.currentAgentMode = session.getAgentMode(); + const backend = createCopilotBackend({ agentMode: this.currentAgentMode }); + this.backend = backend; + registerAcpSessionTitleSync(backend, session.client); + + backend.onStderrError((error) => { + logger.debug('[copilot-remote] stderr error', error); + session.sendSessionEvent({ type: 'message', message: error.message }); + messageBuffer.addMessage(error.message, 'status'); + }); + + await backend.initialize(); + + const resumeSessionId = session.sessionId; + const acpMcpServers = toAcpMcpServers(mcpServers); + let acpSessionId: string; + if (resumeSessionId) { + try { + acpSessionId = await backend.loadSession({ + sessionId: resumeSessionId, + cwd: session.path, + mcpServers: acpMcpServers + }); + } catch (error) { + logger.warn('[copilot-remote] resume failed, starting new session', error); + session.sendSessionEvent({ + type: 'message', + message: 'Copilot resume failed; starting a new session.' + }); + acpSessionId = await backend.newSession({ + cwd: session.path, + mcpServers: acpMcpServers + }); + } + } else { + acpSessionId = await backend.newSession({ + cwd: session.path, + mcpServers: acpMcpServers + }); + } + session.onSessionFound(acpSessionId); + this.activeSessionId = acpSessionId; + session.setRemoteAgentModeApplier((agentMode) => this.applyAgentMode(agentMode)); + + this.permissionHandler = new CopilotPermissionHandler( + session.client, + backend, + () => session.getPermissionMode() as PermissionMode | undefined + ); + + let effectiveModel: string | null = null; + if (runtimeConfig.model) { + effectiveModel = await this.applyInitialModel(backend, acpSessionId, runtimeConfig.model); + } + if (!effectiveModel) { + effectiveModel = backend.getConfigOptionByCategory(acpSessionId, 'model')?.currentValue + ?? backend.getSessionModelsMetadata(acpSessionId)?.currentModelId + ?? null; + } + this.currentBackendModel = effectiveModel; + if (runtimeConfig.model && effectiveModel !== runtimeConfig.model) { + this.rollbackModel(); + } + if (effectiveModel) { + this.displayModel = effectiveModel; + messageBuffer.addMessage(`[MODEL:${effectiveModel}]`, 'system'); + } + this.applyDisplayMode(session.getPermissionMode() as PermissionMode, effectiveModel ?? undefined); + // Resume / session metadata may not inherit spawn `--mode`; apply via ACP set_mode. + await this.applyInitialAgentMode(); + + session.client.rpcHandlerManager.registerHandler(RPC_METHODS.ListCopilotModels, async () => { + return await buildCopilotModelsResponseFromBackend(acpSessionId, backend, session.path); + }); + + this.setupAbortHandlers(session.client.rpcHandlerManager, { + onAbort: () => this.handleAbort(), + onSwitch: () => this.handleSwitchRequest() + }); + + const sendReady = () => { + session.sendSessionEvent({ type: 'ready' }); + }; + + while (!this.shouldExit) { + const batch = await session.queue.waitForMessagesAndGetAsString(this.abortController.signal); + if (!batch) { + if (this.abortController.signal.aborted && !this.shouldExit) { + continue; + } + break; + } + + if (batch.mode.model && batch.mode.model !== this.currentBackendModel) { + batch.mode.model = await this.applyQueuedModel(batch.mode.model) ?? undefined; + } + + const desiredAgentMode = batch.mode.agentMode ?? session.getAgentMode(); + if (desiredAgentMode !== this.currentAgentMode) { + await this.applyAgentMode(desiredAgentMode); + } + + this.applyDisplayMode(batch.mode.permissionMode, batch.mode.model); + + // Empty isolated ticks can update non-mode session config without + // inventing a user prompt. + if (batch.message.length === 0) { + if (session.queue.size() === 0 && !this.shouldExit) { + sendReady(); + } + continue; + } + + messageBuffer.addMessage(batch.message, 'user'); + + const promptContent: PromptContent[] = [{ + type: 'text', + text: batch.message + }]; + + session.onThinkingChange(true); + + try { + await backend.prompt(acpSessionId, promptContent, (message: AgentMessage) => { + this.handleAgentMessage(message); + }); + void backend.refreshSessionInfo(acpSessionId, session.path); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + logger.warn('[copilot-remote] prompt failed', { message: errorMessage }); + session.sendSessionEvent({ + type: 'message', + message: `Copilot prompt failed: ${errorMessage}` + }); + messageBuffer.addMessage(`Copilot prompt failed: ${errorMessage}`, 'status'); + } finally { + session.onThinkingChange(false); + await this.permissionHandler?.cancelAll('Prompt finished'); + if (session.queue.size() === 0 && !this.shouldExit) { + sendReady(); + } + } + } + } + + protected async cleanup(): Promise { + this.clearAbortHandlers(this.session.client.rpcHandlerManager); + this.session.setRemoteAgentModeApplier(null); + this.activeSessionId = null; + + if (this.permissionHandler) { + await this.permissionHandler.cancelAll('Session ended'); + this.permissionHandler = null; + } + + if (this.backend) { + await this.backend.disconnect(); + this.backend = null; + } + + if (this.happyServer) { + this.happyServer.stop(); + this.happyServer = null; + } + } + + private handleAgentMessage(message: AgentMessage): void { + const converted = convertAgentMessage(message, this.currentBackendModel); + if (converted) { + this.session.sendAgentMessage(converted); + } + + switch (message.type) { + case 'text': + this.messageBuffer.addMessage(message.text, 'assistant'); + break; + case 'reasoning': + this.messageBuffer.addMessage(`[Thinking] ${message.text.substring(0, 100)}...`, 'system'); + break; + case 'tool_call': { + const lastName = this.lastDisplayedToolCall.get(message.id); + if (lastName !== message.name) { + this.messageBuffer.addMessage(`Tool call: ${message.name}`, 'tool'); + this.lastDisplayedToolCall.set(message.id, message.name); + } + break; + } + case 'tool_result': + this.messageBuffer.addMessage('Tool result received', 'result'); + break; + case 'usage': + break; + case 'plan': + this.messageBuffer.addMessage('Plan updated', 'status'); + break; + case 'error': + this.messageBuffer.addMessage(message.message, 'status'); + break; + case 'turn_complete': + this.messageBuffer.addMessage('Turn complete', 'status'); + break; + default: { + const _exhaustive: never = message; + return _exhaustive; + } + } + } + + private async applyInitialModel( + backend: ReturnType, + sessionId: string, + model: string + ): Promise { + try { + await backend.setModel(sessionId, model); + this.setModelSupported = true; + return model; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (/method not found/i.test(message)) { + this.setModelSupported = false; + } + logger.debug('[copilot-remote] session/set_model failed, trying model config option', error); + } + + const option = backend.getConfigOptionByCategory(sessionId, 'model'); + if (!option) { + logger.warn(`[copilot-remote] Cannot apply model ${model}: agent exposes no model config option`); + return null; + } + try { + await backend.setConfigOption(sessionId, option.id, model); + return model; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.warn(`[copilot-remote] Failed to apply model ${model}`, error); + this.session.sendSessionEvent({ + type: 'message', + message: `Failed to switch model to ${model}: ${message}. Using the agent default.` + }); + return null; + } + } + + private applyDisplayMode(permissionMode: PermissionMode | undefined, model?: string): void { + if (permissionMode && permissionMode !== this.displayPermissionMode) { + this.displayPermissionMode = permissionMode; + this.messageBuffer.addMessage(`[MODE:${permissionMode}]`, 'system'); + } + if (model && model !== this.displayModel) { + this.displayModel = model; + this.messageBuffer.addMessage(`[MODEL:${model}]`, 'system'); + } + } + + private applyDisplayAgentMode(agentMode: CopilotAgentMode): void { + if (agentMode !== this.displayAgentMode) { + this.displayAgentMode = agentMode; + this.messageBuffer.addMessage(`[AGENT_MODE:${agentMode}]`, 'system'); + this.messageBuffer.addMessage(`Copilot agent mode: ${getCopilotAgentModeLabel(agentMode)}`, 'status'); + } + } + + public async applyAgentMode(agentMode: CopilotAgentMode): Promise { + const backend = this.backend; + const sessionId = this.activeSessionId; + if (!backend || !sessionId) { + throw new Error('Copilot agent mode switching is unavailable before the remote session is ready'); + } + if (this.setModeSupported === false) { + throw new Error('This Copilot CLI build does not support agent mode switching'); + } + + try { + await backend.setMode(sessionId, agentMode); + this.setModeSupported = true; + logger.debug(`[copilot-remote] Applied agent mode via setMode: ${agentMode}`); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (/method not found|does not support session\/set_mode|no mode config option/i.test(message)) { + this.setModeSupported = false; + logger.warn('[copilot-remote] Copilot CLI build does not support set_mode; agent mode changes require restart'); + this.session.sendSessionEvent({ + type: 'message', + message: 'This Copilot CLI build does not support agent mode switching. Restart the session to apply Plan or Autopilot.' + }); + } + if (this.setModeSupported === false) { + throw error; + } + logger.warn('[copilot-remote] Failed to apply agent mode', error); + this.session.sendSessionEvent({ + type: 'message', + message: `Failed to switch Copilot agent mode to ${getCopilotAgentModeLabel(agentMode)}: ${message}` + }); + throw error; + } + + this.currentAgentMode = agentMode; + this.applyDisplayAgentMode(agentMode); + } + + private async applyInitialAgentMode(): Promise { + const requestedMode = this.currentAgentMode; + try { + await this.applyAgentMode(requestedMode); + } catch (error) { + if (this.setModeSupported !== false) { + throw error; + } + // The Copilot process was spawned with --mode, so unavailable runtime + // switching must not prevent startup from reflecting that initial mode. + this.currentAgentMode = requestedMode; + this.applyDisplayAgentMode(requestedMode); + } + } + + private async applyQueuedModel(model: string): Promise { + const backend = this.backend; + const sessionId = this.activeSessionId; + if (!backend || !sessionId) { + throw new Error('Copilot model switching is unavailable before the remote session is ready'); + } + + logger.debug(`[copilot-remote] Switching model inline: ${this.currentBackendModel} -> ${model}`); + if (backend.setModel && this.setModelSupported !== false) { + try { + await backend.setModel(sessionId, model); + this.currentBackendModel = model; + this.setModelSupported = true; + return model; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (/method not found/i.test(message)) { + this.setModelSupported = false; + logger.debug('[copilot-remote] session/set_model unavailable; trying model config option', error); + } else { + logger.warn('[copilot-remote] Inline model switch failed', error); + this.session.sendSessionEvent({ + type: 'message', + message: `Failed to switch model to ${model}. Continuing with ${this.currentBackendModel}.` + }); + return this.rollbackModel(); + } + } + } + + const option = backend.getConfigOptionByCategory(sessionId, 'model'); + if (!option) { + return this.rollbackModel(); + } + try { + await backend.setConfigOption(sessionId, option.id, model); + this.currentBackendModel = model; + return model; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.warn('[copilot-remote] Inline model config option switch failed', error); + this.session.sendSessionEvent({ + type: 'message', + message: `Failed to switch model to ${model}: ${message}. Continuing with ${this.currentBackendModel}.` + }); + return this.rollbackModel(); + } + } + + private rollbackModel(): string | null { + const model = this.currentBackendModel === 'auto' ? null : this.currentBackendModel; + this.session.setModel(model); + this.session.pushKeepAlive(); + this.onModelRollback?.(model); + return this.currentBackendModel; + } + + private async handleAbort(): Promise { + const backend = this.backend; + if (backend && this.session.sessionId) { + await backend.cancelPrompt(this.session.sessionId); + } + await this.permissionHandler?.cancelAll('User aborted'); + this.session.sendSessionEvent({ type: 'message', message: 'Session aborted' }); + this.session.queue.reset(); + this.session.onThinkingChange(false); + this.abortController.abort(); + this.abortController = new AbortController(); + this.messageBuffer.addMessage('Turn aborted', 'status'); + } + + private async handleExitFromUi(): Promise { + await this.requestExit('exit', () => this.handleAbort()); + } + + private async handleSwitchFromUi(): Promise { + await this.requestExit('switch', () => this.handleAbort()); + } + + private async handleSwitchRequest(): Promise { + await this.requestExit('switch', () => this.handleAbort()); + } +} + +function toAcpMcpServers(config: Record): McpServerStdio[] { + return Object.entries(config).map(([name, entry]) => ({ + name, + command: entry.command, + args: entry.args, + env: [] + })); +} + +export async function copilotRemoteLauncher( + session: CopilotSession, + opts: { model?: string; onModelRollback?: (model: string | null) => void } +): Promise<'switch' | 'exit'> { + const launcher = new CopilotRemoteLauncher(session, opts); + return launcher.launch(); +} diff --git a/cli/src/copilot/loop.ts b/cli/src/copilot/loop.ts new file mode 100644 index 00000000..6ebabb50 --- /dev/null +++ b/cli/src/copilot/loop.ts @@ -0,0 +1,66 @@ +import { MessageQueue2 } from '@/utils/MessageQueue2'; +import { logger } from '@/ui/logger'; +import { runLocalRemoteSession } from '@/agent/loopBase'; +import { CopilotSession } from './session'; +import { copilotLocalLauncher } from './copilotLocalLauncher'; +import { copilotRemoteLauncher } from './copilotRemoteLauncher'; +import { ApiClient, ApiSessionClient } from '@/lib'; +import type { CopilotAgentMode } from '@hapi/protocol'; +import type { CopilotMode, PermissionMode } from './types'; + +interface CopilotLoopOptions { + path: string; + startingMode?: 'local' | 'remote'; + startedBy?: 'runner' | 'terminal'; + onModeChange: (mode: 'local' | 'remote') => void; + messageQueue: MessageQueue2; + session: ApiSessionClient; + api: ApiClient; + permissionMode?: PermissionMode; + model?: string; + copilotAgentMode?: CopilotAgentMode; + resumeSessionId?: string; + onSessionReady?: (session: CopilotSession) => void; + onModelRollback?: (model: string | null) => void; +} + +export async function copilotLoop(opts: CopilotLoopOptions): Promise { + const logPath = logger.getLogPath(); + const startedBy = opts.startedBy ?? 'terminal'; + const startingMode = opts.startingMode ?? 'local'; + + const session = new CopilotSession({ + api: opts.api, + client: opts.session, + path: opts.path, + sessionId: opts.resumeSessionId ?? null, + logPath, + messageQueue: opts.messageQueue, + onModeChange: opts.onModeChange, + mode: startingMode, + startedBy, + startingMode, + permissionMode: opts.permissionMode ?? 'default', + agentMode: opts.copilotAgentMode ?? 'interactive' + }); + + if (opts.resumeSessionId) { + session.onSessionFound(opts.resumeSessionId); + } + + const getCurrentModel = (): string | undefined => session.getModel() ?? undefined; + + await runLocalRemoteSession({ + session, + startingMode: opts.startingMode, + logTag: 'copilot-loop', + runLocal: (instance) => copilotLocalLauncher(instance, { + model: getCurrentModel() + }), + runRemote: (instance) => copilotRemoteLauncher(instance, { + model: getCurrentModel(), + onModelRollback: opts.onModelRollback + }), + onSessionReady: opts.onSessionReady + }); +} diff --git a/cli/src/copilot/runCopilot.test.ts b/cli/src/copilot/runCopilot.test.ts new file mode 100644 index 00000000..bda84f41 --- /dev/null +++ b/cli/src/copilot/runCopilot.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { CopilotSession } from './session'; +import { applyCopilotSlashAgentMode, resolveCopilotQueueModel } from './runCopilot'; + +describe('applyCopilotSlashAgentMode', () => { + it('rejects without changing the caller mode when Copilot rejects a slash update', async () => { + const activeSession = { + applyRemoteAgentMode: vi.fn().mockRejectedValue(new Error('set_mode failed')) + } as unknown as CopilotSession; + let publishedMode: 'interactive' | 'plan' = 'interactive'; + + await expect(applyCopilotSlashAgentMode(publishedMode, 'plan', activeSession)) + .rejects.toThrow('set_mode failed'); + + expect(publishedMode).toBe('interactive'); + expect(activeSession.applyRemoteAgentMode).toHaveBeenCalledWith('plan'); + }); +}); + +describe('resolveCopilotQueueModel', () => { + it('preserves Auto as an explicit model update', () => { + expect(resolveCopilotQueueModel('gpt-5.6')).toBe('gpt-5.6'); + expect(resolveCopilotQueueModel(null)).toBe('auto'); + }); +}); diff --git a/cli/src/copilot/runCopilot.ts b/cli/src/copilot/runCopilot.ts new file mode 100644 index 00000000..947f8a20 --- /dev/null +++ b/cli/src/copilot/runCopilot.ts @@ -0,0 +1,355 @@ +import { logger } from '@/ui/logger'; +import { randomUUID } from 'node:crypto'; +import { copilotLoop } from './loop'; +import { MessageQueue2 } from '@/utils/MessageQueue2'; +import { hashObject } from '@/utils/deterministicJson'; +import { registerKillSessionHandler } from '@/claude/registerKillSessionHandler'; +import type { AgentState } from '@/api/types'; +import type { CopilotSession } from './session'; +import type { CopilotMode, PermissionMode } from './types'; +import { bootstrapExistingSession, bootstrapSession } from '@/agent/sessionFactory'; +import { registerLocalHandoffHandler } from '@/agent/localHandoff'; +import { createModeChangeHandler, createRunnerLifecycle, setControlledByUser } from '@/agent/runnerLifecycle'; +import { isCopilotAgentMode, isPermissionModeAllowedForFlavor } from '@hapi/protocol'; +import { PermissionModeSchema } from '@hapi/protocol/schemas'; +import { formatMessageWithAttachments } from '@/utils/attachmentFormatter'; +import { getInvokedCwd } from '@/utils/invokedCwd'; +import { resolveCopilotRuntimeConfig } from './utils/config'; +import { listSlashCommands } from '@/modules/common/slashCommands'; +import { resolveCopilotSlashCommand } from './utils/slashCommands'; + +export async function applyCopilotSlashAgentMode( + currentAgentMode: import('@hapi/protocol').CopilotAgentMode, + nextAgentMode: import('@hapi/protocol').CopilotAgentMode | undefined, + activeSession: CopilotSession | null +): Promise { + if (nextAgentMode === undefined || nextAgentMode === currentAgentMode) { + return currentAgentMode; + } + if (!activeSession) { + throw new Error('Copilot remote session is not ready for agent mode switching'); + } + await activeSession.applyRemoteAgentMode(nextAgentMode); + return nextAgentMode; +} + +export function resolveCopilotQueueModel(model: string | null): string { + return model ?? 'auto'; +} + +export async function runCopilot(opts: { + startedBy?: 'runner' | 'terminal'; + startingMode?: 'local' | 'remote'; + permissionMode?: PermissionMode; + model?: string; + copilotAgentMode?: import('@hapi/protocol').CopilotAgentMode; + resumeSessionId?: string; + existingSessionId?: string; + workingDirectory?: string; +} = {}): Promise { + const workingDirectory = opts.workingDirectory ?? getInvokedCwd(); + const startedBy = opts.startedBy ?? 'terminal'; + + logger.debug(`[copilot] Starting with options: startedBy=${startedBy}, startingMode=${opts.startingMode}`); + + if (startedBy === 'runner' && opts.startingMode === 'local') { + logger.debug('[copilot] Runner spawn requested with local mode; forcing remote mode'); + opts.startingMode = 'remote'; + } + + const initialState: AgentState = { + controlledByUser: false + }; + + const runtimeConfig = resolveCopilotRuntimeConfig({ model: opts.model }); + const persistedModel = runtimeConfig.modelSource === 'default' + ? undefined + : runtimeConfig.model; + + const bootstrap = opts.existingSessionId + ? await bootstrapExistingSession({ + sessionId: opts.existingSessionId, + flavor: 'copilot', + startedBy, + workingDirectory + }) + : await bootstrapSession({ + flavor: 'copilot', + startedBy, + workingDirectory, + agentState: initialState, + model: persistedModel + }); + const { api, session } = bootstrap; + + const startingMode: 'local' | 'remote' = opts.startingMode + ?? (startedBy === 'runner' ? 'remote' : 'local'); + + setControlledByUser(session, startingMode); + + const messageQueue = new MessageQueue2((mode) => hashObject({ + permissionMode: mode.permissionMode, + model: mode.model, + agentMode: mode.agentMode + })); + + const sessionWrapperRef: { current: CopilotSession | null } = { current: null }; + let currentPermissionMode: PermissionMode = opts.permissionMode ?? 'default'; + let currentAgentMode = opts.copilotAgentMode ?? 'interactive'; + let sessionModel: string | null = persistedModel ?? null; + let resolvedModel = sessionModel ?? runtimeConfig.model ?? null; + + const lifecycle = createRunnerLifecycle({ + session, + logTag: 'copilot', + stopKeepAlive: () => sessionWrapperRef.current?.stopKeepAlive() + }); + + lifecycle.registerProcessHandlers(); + registerKillSessionHandler(session.rpcHandlerManager, lifecycle); + registerLocalHandoffHandler(session.rpcHandlerManager, lifecycle); + + const syncSessionMode = () => { + const sessionInstance = sessionWrapperRef.current; + if (!sessionInstance) { + return; + } + sessionInstance.setPermissionMode(currentPermissionMode); + sessionInstance.setModel(sessionModel); + sessionInstance.setAgentMode(currentAgentMode); + sessionInstance.pushKeepAlive(); + + logger.debug(`[copilot] Synced session config for keepalive: permissionMode=${currentPermissionMode}, agentMode=${currentAgentMode}, model=${resolvedModel}`); + }; + + const buildMode = (): CopilotMode => ({ + permissionMode: currentPermissionMode, + model: resolvedModel ?? undefined, + agentMode: currentAgentMode + }); + + const preparingLocalIds = new Set(); + const cancelledBeforeEnqueue = new Set(); + let userMessageChain: Promise = Promise.resolve(); + + session.onUserMessage((message, localId) => { + if (localId) preparingLocalIds.add(localId); + userMessageChain = userMessageChain.then(async () => { + const wasCancelled = (): boolean => { + if (!localId) return false; + return cancelledBeforeEnqueue.delete(localId); + }; + const pushPlain = () => { + const formattedText = formatMessageWithAttachments(message.content.text, message.content.attachments); + messageQueue.push(formattedText, buildMode(), localId); + }; + let recognizedSlash = false; + try { + if (wasCancelled()) return; + let text = message.content.text; + const commands = await listSlashCommands('copilot', workingDirectory).catch(() => []); + if (wasCancelled()) return; + const slash = resolveCopilotSlashCommand(text, { + commands, + permissionMode: currentPermissionMode, + model: sessionModel, + agentMode: currentAgentMode + }); + + if (slash.kind !== 'passthrough') { + recognizedSlash = true; + if (sessionWrapperRef.current?.mode === 'local' + && (slash.updates?.permissionMode !== undefined + || slash.updates?.model !== undefined + || slash.updates?.agentMode !== undefined)) { + if (localId) { + session.emitMessagesConsumed([localId], { clearQueuedThinkingGrace: true }); + } + session.sendAgentMessage({ + type: 'message', + message: 'Copilot model, permission mode, and agent mode can only be changed for remote sessions.', + id: randomUUID() + }); + sessionWrapperRef.current.pushKeepAlive(); + return; + } + if (slash.updates) { + const requestedAgentMode = slash.updates.agentMode; + currentAgentMode = await applyCopilotSlashAgentMode( + currentAgentMode, + requestedAgentMode, + sessionWrapperRef.current + ); + if (slash.updates.permissionMode !== undefined) { + currentPermissionMode = slash.updates.permissionMode; + } + if (slash.updates.model !== undefined) { + sessionModel = slash.updates.model; + resolvedModel = resolveCopilotQueueModel(sessionModel); + } + syncSessionMode(); + if (wasCancelled()) return; + } + if (slash.kind === 'handled') { + if (localId) { + session.emitMessagesConsumed([localId], { clearQueuedThinkingGrace: true }); + } + if (slash.message) { + session.sendAgentMessage({ + type: 'message', + message: slash.message, + id: randomUUID() + }); + } + sessionWrapperRef.current?.pushKeepAlive(); + return; + } + if (slash.message) { + session.sendAgentMessage({ + type: 'message', + message: slash.message, + id: randomUUID() + }); + } + text = slash.text; + } + + const formattedText = formatMessageWithAttachments(text, message.content.attachments); + messageQueue.push(formattedText, buildMode(), localId); + } catch (error) { + logger.debug('[copilot] Failed to handle user message', error); + if (wasCancelled()) return; + if (recognizedSlash) { + if (localId) { + session.emitMessagesConsumed([localId], { clearQueuedThinkingGrace: true }); + } + session.sendAgentMessage({ + type: 'message', + message: error instanceof Error ? error.message : 'Failed to apply Copilot slash command', + id: randomUUID() + }); + sessionWrapperRef.current?.pushKeepAlive(); + return; + } + pushPlain(); + } finally { + if (localId) { + preparingLocalIds.delete(localId); + cancelledBeforeEnqueue.delete(localId); + } + } + }).catch((error) => { + logger.debug('[copilot] User message handler chain failed', error); + }); + }); + + session.onCancelQueuedMessage((localId) => { + const removedFromQueue = messageQueue.cancelByLocalId(localId); + if (!removedFromQueue && preparingLocalIds.has(localId)) { + cancelledBeforeEnqueue.add(localId); + } + logger.debug(`[copilot] cancelByLocalId(${localId}): ${removedFromQueue ? 'removed' : 'not found (best-effort)'}`); + return removedFromQueue || cancelledBeforeEnqueue.has(localId); + }); + + const resolvePermissionMode = (value: unknown): PermissionMode => { + const parsed = PermissionModeSchema.safeParse(value); + if (!parsed.success || !isPermissionModeAllowedForFlavor(parsed.data, 'copilot')) { + throw new Error('Invalid permission mode'); + } + return parsed.data as PermissionMode; + }; + + const resolveModel = (value: unknown): string | null => { + if (value === null) { + return null; + } + if (typeof value !== 'string' || value.trim().length === 0) { + throw new Error('Invalid model'); + } + return value.trim(); + }; + + session.rpcHandlerManager.registerHandler('set-session-config', async (payload: unknown) => { + if (!payload || typeof payload !== 'object') { + throw new Error('Invalid session config payload'); + } + const config = payload as { permissionMode?: unknown; model?: unknown; copilotAgentMode?: unknown }; + const applied: Record = {}; + + if (config.permissionMode !== undefined) { + if (sessionWrapperRef.current?.mode === 'local') { + throw new Error('Copilot permission mode can only be changed for remote sessions'); + } + currentPermissionMode = resolvePermissionMode(config.permissionMode); + applied.permissionMode = currentPermissionMode; + } + + if (config.model !== undefined) { + if (sessionWrapperRef.current?.mode === 'local') { + throw new Error('Copilot model can only be changed for remote sessions'); + } + sessionModel = resolveModel(config.model); + resolvedModel = resolveCopilotQueueModel(sessionModel); + applied.model = sessionModel; + } + + if (config.copilotAgentMode !== undefined) { + if (!isCopilotAgentMode(config.copilotAgentMode)) { + throw new Error('Invalid copilot agent mode'); + } + if (config.copilotAgentMode !== currentAgentMode) { + const activeSession = sessionWrapperRef.current; + if (!activeSession) { + throw new Error('Copilot remote session is not ready for agent mode switching'); + } + await activeSession.applyRemoteAgentMode(config.copilotAgentMode); + currentAgentMode = config.copilotAgentMode; + } + applied.copilotAgentMode = currentAgentMode; + } + + syncSessionMode(); + return { applied }; + }); + + let crashed = false; + + try { + await copilotLoop({ + path: workingDirectory, + startingMode, + startedBy, + messageQueue, + session, + api, + permissionMode: currentPermissionMode, + model: runtimeConfig.model, + copilotAgentMode: currentAgentMode, + resumeSessionId: opts.resumeSessionId, + onModeChange: createModeChangeHandler(session), + onSessionReady: (instance) => { + sessionWrapperRef.current = instance; + syncSessionMode(); + }, + onModelRollback: (model) => { + sessionModel = model; + resolvedModel = resolveCopilotQueueModel(model); + } + }); + } catch (error) { + crashed = true; + lifecycle.markCrash(error); + logger.debug('[copilot] Loop error:', error); + } finally { + const localFailure = sessionWrapperRef.current?.localLaunchFailure; + if (localFailure?.exitReason === 'exit') { + lifecycle.setExitCode(1); + lifecycle.setArchiveReason(`Local launch failed: ${localFailure.message.slice(0, 200)}`); + lifecycle.setSessionEndReason('error'); + } else if (!crashed) { + lifecycle.setSessionEndReason('completed'); + } + await lifecycle.cleanupAndExit(); + } +} diff --git a/cli/src/copilot/session.ts b/cli/src/copilot/session.ts new file mode 100644 index 00000000..335d6f7b --- /dev/null +++ b/cli/src/copilot/session.ts @@ -0,0 +1,117 @@ +import { ApiClient, ApiSessionClient } from '@/lib'; +import { MessageQueue2 } from '@/utils/MessageQueue2'; +import { AgentSessionBase } from '@/agent/sessionBase'; +import type { CopilotMode, PermissionMode } from './types'; +import type { CopilotAgentMode } from '@hapi/protocol'; +import type { LocalLaunchExitReason } from '@/agent/localLaunchPolicy'; + +type LocalLaunchFailure = { + message: string; + exitReason: LocalLaunchExitReason; +}; + +export class CopilotSession extends AgentSessionBase { + readonly startedBy: 'runner' | 'terminal'; + readonly startingMode: 'local' | 'remote'; + localLaunchFailure: LocalLaunchFailure | null = null; + private remoteAgentModeApplier: ((mode: CopilotAgentMode) => Promise) | null = null; + + constructor(opts: { + api: ApiClient; + client: ApiSessionClient; + path: string; + logPath: string; + sessionId: string | null; + messageQueue: MessageQueue2; + onModeChange: (mode: 'local' | 'remote') => void; + mode?: 'local' | 'remote'; + startedBy: 'runner' | 'terminal'; + startingMode: 'local' | 'remote'; + permissionMode?: PermissionMode; + agentMode?: CopilotAgentMode; + }) { + super({ + api: opts.api, + client: opts.client, + path: opts.path, + logPath: opts.logPath, + sessionId: opts.sessionId, + messageQueue: opts.messageQueue, + onModeChange: opts.onModeChange, + mode: opts.mode, + sessionLabel: 'CopilotSession', + sessionIdLabel: 'Copilot', + applySessionIdToMetadata: (metadata, sessionId) => ({ + ...metadata, + copilotSessionId: sessionId + }), + permissionMode: opts.permissionMode + }); + + this.startedBy = opts.startedBy; + this.startingMode = opts.startingMode; + this.permissionMode = opts.permissionMode; + this.agentMode = opts.agentMode ?? 'interactive'; + } + + agentMode: CopilotAgentMode; + + setPermissionMode = (mode: PermissionMode): void => { + this.permissionMode = mode; + }; + + setModel = (model: string | null): void => { + this.model = model; + }; + + setAgentMode = (mode: CopilotAgentMode): void => { + this.agentMode = mode; + }; + + getAgentMode = (): CopilotAgentMode => this.agentMode; + + setRemoteAgentModeApplier = (applier: ((mode: CopilotAgentMode) => Promise) | null): void => { + this.remoteAgentModeApplier = applier; + }; + + applyRemoteAgentMode = async (mode: CopilotAgentMode): Promise => { + if (this.thinking) { + throw new Error('Wait for the current Copilot turn to finish before changing agent mode'); + } + if (!this.remoteAgentModeApplier) { + throw new Error('Copilot agent mode switching is unavailable for this session'); + } + await this.remoteAgentModeApplier(mode); + }; + + protected override getKeepAliveRuntime() { + return { + ...(super.getKeepAliveRuntime() ?? {}), + copilotAgentMode: this.agentMode + }; + } + + pushKeepAlive = (): void => { + this.client.keepAlive(this.thinking, this.mode, { + permissionMode: this.permissionMode, + model: this.model, + copilotAgentMode: this.agentMode + }); + }; + + recordLocalLaunchFailure = (message: string, exitReason: LocalLaunchExitReason): void => { + this.localLaunchFailure = { message, exitReason }; + }; + + sendAgentMessage = (message: unknown): void => { + this.client.sendAgentMessage(message); + }; + + sendUserMessage = (text: string): void => { + this.client.sendUserMessage(text); + }; + + sendSessionEvent = (event: Parameters[0]): void => { + this.client.sendSessionEvent(event); + }; +} diff --git a/cli/src/copilot/types.ts b/cli/src/copilot/types.ts new file mode 100644 index 00000000..1952c7ed --- /dev/null +++ b/cli/src/copilot/types.ts @@ -0,0 +1,10 @@ +import type { CopilotPermissionMode } from '@hapi/protocol/types'; +import type { CopilotAgentMode } from '@hapi/protocol'; + +export type PermissionMode = CopilotPermissionMode; + +export interface CopilotMode { + permissionMode: PermissionMode; + model?: string; + agentMode?: CopilotAgentMode; +} diff --git a/cli/src/copilot/utils/config.ts b/cli/src/copilot/utils/config.ts new file mode 100644 index 00000000..6a3c478b --- /dev/null +++ b/cli/src/copilot/utils/config.ts @@ -0,0 +1,10 @@ +export type CopilotModelSource = 'explicit' | 'default'; + +export function resolveCopilotRuntimeConfig(opts: { + model?: string; +} = {}): { model: string | undefined; modelSource: CopilotModelSource } { + if (opts.model) { + return { model: opts.model, modelSource: 'explicit' }; + } + return { model: undefined, modelSource: 'default' }; +} diff --git a/cli/src/copilot/utils/copilotBackend.test.ts b/cli/src/copilot/utils/copilotBackend.test.ts new file mode 100644 index 00000000..b0db00fb --- /dev/null +++ b/cli/src/copilot/utils/copilotBackend.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, test } from 'vitest'; +import { buildCopilotAcpArgs, createCopilotBackend } from './copilotBackend'; + +describe('buildCopilotAcpArgs', () => { + test('defaults to acp stdio without mode', () => { + expect(buildCopilotAcpArgs()).toEqual(['--acp', '--stdio']); + expect(buildCopilotAcpArgs({ agentMode: 'interactive' })).toEqual(['--acp', '--stdio']); + }); + + test('passes --mode for plan and autopilot', () => { + expect(buildCopilotAcpArgs({ agentMode: 'plan' })).toEqual(['--acp', '--stdio', '--mode', 'plan']); + expect(buildCopilotAcpArgs({ agentMode: 'autopilot' })).toEqual([ + '--acp', + '--stdio', + '--mode', + 'autopilot' + ]); + }); +}); + +describe('createCopilotBackend', () => { + test('creates an ACP backend for copilot', () => { + const backend = createCopilotBackend({ agentMode: 'plan' }); + expect(backend).toBeDefined(); + }); +}); diff --git a/cli/src/copilot/utils/copilotBackend.ts b/cli/src/copilot/utils/copilotBackend.ts new file mode 100644 index 00000000..965ce940 --- /dev/null +++ b/cli/src/copilot/utils/copilotBackend.ts @@ -0,0 +1,29 @@ +import { AcpSdkBackend } from '@/agent/backends/acp'; +import type { CopilotAgentMode } from '@hapi/protocol'; + +function filterEnv(env: NodeJS.ProcessEnv): Record { + const result: Record = {}; + for (const [key, value] of Object.entries(env)) { + if (value !== undefined) { + result[key] = value; + } + } + return result; +} + +/** ACP process args. Non-interactive modes use `--mode` so Plan/Autopilot apply at spawn. */ +export function buildCopilotAcpArgs(opts?: { agentMode?: CopilotAgentMode }): string[] { + const args = ['--acp', '--stdio']; + if (opts?.agentMode && opts.agentMode !== 'interactive') { + args.push('--mode', opts.agentMode); + } + return args; +} + +export function createCopilotBackend(opts?: { agentMode?: CopilotAgentMode }): AcpSdkBackend { + return new AcpSdkBackend({ + command: process.env.COPILOT_CLI_PATH ?? 'copilot', + args: buildCopilotAcpArgs(opts), + env: filterEnv(process.env) + }); +} diff --git a/cli/src/copilot/utils/copilotSessionLocator.test.ts b/cli/src/copilot/utils/copilotSessionLocator.test.ts new file mode 100644 index 00000000..b839cc09 --- /dev/null +++ b/cli/src/copilot/utils/copilotSessionLocator.test.ts @@ -0,0 +1,137 @@ +import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { mapCopilotLocalApprovalMode } from '../copilotLocalLauncher'; +import { + createCopilotSessionLocator, + parseCopilotWorkspaceYaml +} from './copilotSessionLocator'; + +describe('mapCopilotLocalApprovalMode', () => { + it('only escalates full yolo to --allow-all', () => { + expect(mapCopilotLocalApprovalMode('yolo')).toEqual({ yolo: true }); + expect(mapCopilotLocalApprovalMode('safe-yolo')).toEqual({ yolo: false }); + expect(mapCopilotLocalApprovalMode('default')).toEqual({ yolo: false }); + expect(mapCopilotLocalApprovalMode('read-only')).toEqual({ yolo: false }); + expect(mapCopilotLocalApprovalMode(undefined)).toEqual({ yolo: false }); + }); +}); + +describe('parseCopilotWorkspaceYaml', () => { + it('reads id and cwd fields', () => { + expect(parseCopilotWorkspaceYaml([ + 'id: abc-123', + 'cwd: /home/ubuntu/hapi', + 'branch: main' + ].join('\n'))).toEqual({ + id: 'abc-123', + cwd: '/home/ubuntu/hapi' + }); + }); + + it('unquotes special characters in cwd values', () => { + expect(parseCopilotWorkspaceYaml([ + 'id: abc-123', + 'cwd: "/home/ubuntu/project #1"' + ].join('\n'))).toEqual({ + id: 'abc-123', + cwd: '/home/ubuntu/project #1' + }); + }); +}); + +describe('createCopilotSessionLocator', () => { + const locators: Array<{ cleanup: () => Promise }> = []; + + afterEach(async () => { + while (locators.length > 0) { + await locators.pop()?.cleanup(); + } + }); + + it('locates a fresh session for the working directory', async () => { + const root = await mkdtemp(join(tmpdir(), 'copilot-locator-')); + const sessionId = 'fresh-session-1'; + const sessionDir = join(root, sessionId); + + const located = vi.fn(); + const locator = createCopilotSessionLocator({ + cwd: '/work/project', + startupTimestampMs: Date.now() - 1000, + sessionStateRoot: root, + intervalMs: 50, + onLocated: located + }); + locators.push(locator); + await locator.ready; + + await mkdir(sessionDir, { recursive: true }); + await writeFile(join(sessionDir, 'workspace.yaml'), [ + `id: ${sessionId}`, + 'cwd: /work/project', + 'client_name: github/cli' + ].join('\n')); + + await vi.waitFor(() => { + expect(located).toHaveBeenCalledWith({ + sessionId, + sessionDir + }); + }, { timeout: 2000 }); + }); + + it('locates a session with a quoted cwd containing special characters', async () => { + const root = await mkdtemp(join(tmpdir(), 'copilot-locator-')); + const sessionId = 'quoted-cwd'; + const sessionDir = join(root, sessionId); + const cwd = '/work/project #1'; + const located = vi.fn(); + const locator = createCopilotSessionLocator({ + cwd, + startupTimestampMs: Date.now() - 1000, + sessionStateRoot: root, + intervalMs: 50, + onLocated: located + }); + locators.push(locator); + + await locator.ready; + await mkdir(sessionDir, { recursive: true }); + await writeFile(join(sessionDir, 'workspace.yaml'), [ + `id: ${sessionId}`, + `cwd: "${cwd}"` + ].join('\n')); + await vi.waitFor(() => { + expect(located).toHaveBeenCalledWith({ + sessionId, + sessionDir + }); + }, { timeout: 2000 }); + }); + + it('ignores sessions for other working directories', async () => { + const root = await mkdtemp(join(tmpdir(), 'copilot-locator-')); + const sessionDir = join(root, 'other-cwd'); + await mkdir(sessionDir, { recursive: true }); + await writeFile(join(sessionDir, 'workspace.yaml'), [ + 'id: other-cwd', + 'cwd: /work/other', + 'client_name: github/cli' + ].join('\n')); + + const located = vi.fn(); + const locator = createCopilotSessionLocator({ + cwd: '/work/project', + startupTimestampMs: Date.now() - 1000, + sessionStateRoot: root, + intervalMs: 50, + onLocated: located + }); + locators.push(locator); + + await locator.ready; + await new Promise((resolve) => setTimeout(resolve, 200)); + expect(located).not.toHaveBeenCalled(); + }); +}); diff --git a/cli/src/copilot/utils/copilotSessionLocator.ts b/cli/src/copilot/utils/copilotSessionLocator.ts new file mode 100644 index 00000000..c88507eb --- /dev/null +++ b/cli/src/copilot/utils/copilotSessionLocator.ts @@ -0,0 +1,220 @@ +import { readdir, readFile, stat } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; +import { homedir } from 'node:os'; +import { parse as parseYaml } from 'yaml'; +import { logger } from '@/ui/logger'; + +export type LocatedCopilotSession = { + sessionId: string; + sessionDir: string; +}; + +export type CopilotSessionLocator = { + ready: Promise; + cleanup: () => Promise; +}; + +type CopilotSessionLocatorOptions = { + cwd: string; + startupTimestampMs: number; + resumeSessionId?: string | null; + intervalMs?: number; + sessionStateRoot?: string; + onLocated: (located: LocatedCopilotSession) => void; + onAmbiguous?: (sessionIds: string[]) => void; +}; + +const DEFAULT_LOCATOR_INTERVAL_MS = 500; +const STARTUP_GRACE_MS = 2000; + +export function getCopilotSessionStateRoot(): string { + return join(process.env.HOME ?? process.env.USERPROFILE ?? homedir(), '.copilot', 'session-state'); +} + +function normalizePath(path: string): string { + return resolve(path).replace(/\\/g, '/').replace(/\/+$/, ''); +} + +/** Minimal workspace.yaml field extraction (id + cwd). */ +export function parseCopilotWorkspaceYaml(content: string): { id?: string; cwd?: string } { + let parsed: unknown; + try { + parsed = parseYaml(content); + } catch { + return {}; + } + if (!parsed || typeof parsed !== 'object') { + return {}; + } + const { id, cwd } = parsed as { id?: unknown; cwd?: unknown }; + return { + id: typeof id === 'string' ? id : undefined, + cwd: typeof cwd === 'string' ? cwd : undefined + }; +} + +/** + * Polls ~/.copilot/session-state for the session the locally spawned Copilot + * TUI just created in this working directory, then persists its id via onLocated. + */ +export function createCopilotSessionLocator(options: CopilotSessionLocatorOptions): CopilotSessionLocator { + const locator = new CopilotSessionLocatorImpl(options); + const ready = locator.start().catch((error) => { + logger.debug('[copilot-session-locator] Failed to initialize', error); + }); + return { + ready, + cleanup: async () => { + await locator.cleanup(); + await ready; + } + }; +} + +class CopilotSessionLocatorImpl { + private readonly sessionStateRoot: string; + private readonly targetCwd: string; + private readonly startupTimestampMs: number; + private readonly resumeSessionId: string | null; + private readonly intervalMs: number; + private readonly onLocated: CopilotSessionLocatorOptions['onLocated']; + private readonly onAmbiguous?: CopilotSessionLocatorOptions['onAmbiguous']; + private readonly initialSessionIds = new Set(); + private interval: ReturnType | null = null; + private scanPromise: Promise | null = null; + private stopped = false; + + constructor(options: CopilotSessionLocatorOptions) { + this.sessionStateRoot = options.sessionStateRoot ?? getCopilotSessionStateRoot(); + this.targetCwd = normalizePath(options.cwd); + this.startupTimestampMs = options.startupTimestampMs; + this.resumeSessionId = options.resumeSessionId ?? null; + this.intervalMs = options.intervalMs ?? DEFAULT_LOCATOR_INTERVAL_MS; + this.onLocated = options.onLocated; + this.onAmbiguous = options.onAmbiguous; + } + + async start(): Promise { + if (!this.resumeSessionId) { + try { + const entries = await readdir(this.sessionStateRoot, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isDirectory()) { + this.initialSessionIds.add(entry.name); + } + } + } catch { + // Session-state root may not exist yet. + } + } + if (this.stopped) return; + + void this.scan(); + this.interval = setInterval(() => void this.scan(), this.intervalMs); + this.interval.unref?.(); + } + + async cleanup(): Promise { + this.stopped = true; + if (this.interval) { + clearInterval(this.interval); + this.interval = null; + } + await this.scanPromise?.catch(() => {}); + } + + private stopPolling(): void { + this.stopped = true; + if (this.interval) { + clearInterval(this.interval); + this.interval = null; + } + } + + private async scan(): Promise { + if (this.stopped || this.scanPromise) { + return this.scanPromise ?? Promise.resolve(); + } + this.scanPromise = this.runScan(); + try { + await this.scanPromise; + } finally { + this.scanPromise = null; + } + } + + private async runScan(): Promise { + const candidates = await this.listCandidates(); + if (this.stopped || candidates.length === 0) { + return; + } + + if (candidates.length > 1) { + logger.warn( + `[copilot-session-locator] Ambiguous Copilot sessions (${candidates.length} fresh candidates); refusing attachment`, + candidates.map((candidate) => candidate.sessionId) + ); + this.stopPolling(); + this.onAmbiguous?.(candidates.map((candidate) => candidate.sessionId)); + return; + } + + const [located] = candidates; + logger.debug(`[copilot-session-locator] Located ${located.sessionId}`); + this.stopPolling(); + this.onLocated(located); + } + + private async listCandidates(): Promise { + let entries; + try { + entries = await readdir(this.sessionStateRoot, { withFileTypes: true }); + } catch { + return []; + } + + const candidates: LocatedCopilotSession[] = []; + for (const entry of entries) { + if (!entry.isDirectory()) { + continue; + } + if (this.resumeSessionId) { + if (entry.name !== this.resumeSessionId) { + continue; + } + } else if (this.initialSessionIds.has(entry.name)) { + continue; + } + + const sessionDir = join(this.sessionStateRoot, entry.name); + const workspacePath = join(sessionDir, 'workspace.yaml'); + const workspaceStats = await stat(workspacePath).catch(() => null); + if (!workspaceStats || !workspaceStats.isFile()) { + continue; + } + + if (!this.resumeSessionId) { + const birthMs = workspaceStats.birthtimeMs || workspaceStats.ctimeMs; + if (birthMs + STARTUP_GRACE_MS < this.startupTimestampMs) { + continue; + } + } + + const content = await readFile(workspacePath, 'utf8').catch(() => null); + if (!content) { + continue; + } + const parsed = parseCopilotWorkspaceYaml(content); + const sessionId = parsed.id ?? entry.name; + if (this.resumeSessionId && sessionId !== this.resumeSessionId) { + continue; + } + if (!parsed.cwd || normalizePath(parsed.cwd) !== this.targetCwd) { + continue; + } + + candidates.push({ sessionId, sessionDir }); + } + return candidates; + } +} diff --git a/cli/src/copilot/utils/permissionHandler.test.ts b/cli/src/copilot/utils/permissionHandler.test.ts new file mode 100644 index 00000000..7a05d224 --- /dev/null +++ b/cli/src/copilot/utils/permissionHandler.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { AgentBackend, PermissionRequest } from '@/agent/types'; +import type { ApiSessionClient } from '@/api/apiSession'; +import { CopilotPermissionHandler, mapCopilotPermissionDecision } from './permissionHandler'; + +describe('mapCopilotPermissionDecision', () => { + it('cancels an explicit denial when only an allow option is available', () => { + const request: PermissionRequest = { + id: 'permission-1', + sessionId: 'session-1', + toolCallId: 'tool-1', + options: [{ + optionId: 'allow-once', + name: 'Allow once', + kind: 'allow_once' + }] + }; + + expect(mapCopilotPermissionDecision(request, 'denied')).toEqual({ + outcome: 'cancelled' + }); + }); + + it('cancels an explicit approval when only a reject option is available', () => { + const request: PermissionRequest = { + id: 'permission-1', + sessionId: 'session-1', + toolCallId: 'tool-1', + options: [{ + optionId: 'reject-once', + name: 'Reject once', + kind: 'reject_once' + }] + }; + + expect(mapCopilotPermissionDecision(request, 'approved')).toEqual({ + outcome: 'cancelled' + }); + }); + + it('keeps Bash pending in read-only mode', () => { + let onPermissionRequest: ((request: PermissionRequest) => void) | undefined; + const updateAgentState = vi.fn(); + const backend = { + onPermissionRequest: (handler: (request: PermissionRequest) => void) => { + onPermissionRequest = handler; + }, + respondToPermission: vi.fn() + } as unknown as AgentBackend; + const session = { + rpcHandlerManager: { registerHandler: vi.fn() }, + updateAgentState + } as unknown as ApiSessionClient; + + new CopilotPermissionHandler(session, backend, () => 'read-only'); + onPermissionRequest?.({ + id: 'permission-1', + sessionId: 'session-1', + toolCallId: 'tool-1', + title: 'Bash', + options: [{ optionId: 'allow-once', name: 'Allow once', kind: 'allow_once' }] + }); + + expect(backend.respondToPermission).not.toHaveBeenCalled(); + expect(updateAgentState).toHaveBeenCalledOnce(); + }); +}); diff --git a/cli/src/copilot/utils/permissionHandler.ts b/cli/src/copilot/utils/permissionHandler.ts new file mode 100644 index 00000000..6268bea9 --- /dev/null +++ b/cli/src/copilot/utils/permissionHandler.ts @@ -0,0 +1,190 @@ +import type { ApiSessionClient } from '@/api/apiSession'; +import type { AgentBackend, PermissionRequest, PermissionResponse } from '@/agent/types'; +import type { CopilotPermissionMode } from '@hapi/protocol/types'; +import { deriveToolName } from '@/agent/utils'; +import { logger } from '@/ui/logger'; +import { + BasePermissionHandler, + type AutoApprovalDecision, + type PendingPermissionRequest, + type PermissionCompletion +} from '@/modules/common/permission/BasePermissionHandler'; + +interface PermissionResponseMessage { + id: string; + approved: boolean; + decision?: 'approved' | 'approved_for_session' | 'denied' | 'abort'; + reason?: string; +} + +const READ_ONLY_TOOL_NAMES = new Set([ + 'read', + 'read file', + 'grep', + 'glob', + 'search', + 'list files' +]); + +function deriveToolInput(request: PermissionRequest): unknown { + if (request.rawInput !== undefined) { + return request.rawInput; + } + return request.rawOutput; +} + +function pickOptionId( + request: PermissionRequest, + preferredKinds: string[], + fallbackToFirstOption = true +): string | null { + for (const kind of preferredKinds) { + const match = request.options.find((option) => option.kind === kind); + if (match) { + return match.optionId; + } + } + return fallbackToFirstOption && request.options.length > 0 + ? request.options[0].optionId + : null; +} + +export function mapCopilotPermissionDecision( + request: PermissionRequest, + decision: PermissionResponseMessage['decision'] +): PermissionResponse { + if (decision === 'abort') { + return { outcome: 'cancelled' }; + } + + if (decision === 'approved_for_session') { + const optionId = pickOptionId(request, ['allow_always', 'allow_once'], false); + return optionId ? { outcome: 'selected', optionId } : { outcome: 'cancelled' }; + } + + if (decision === 'approved') { + const optionId = pickOptionId(request, ['allow_once', 'allow_always'], false); + return optionId ? { outcome: 'selected', optionId } : { outcome: 'cancelled' }; + } + + const optionId = pickOptionId(request, ['reject_once', 'reject_always'], false); + return optionId ? { outcome: 'selected', optionId } : { outcome: 'cancelled' }; +} + +export class CopilotPermissionHandler extends BasePermissionHandler { + private readonly pendingBackendRequests = new Map(); + + constructor( + session: ApiSessionClient, + private readonly backend: AgentBackend, + private readonly getPermissionMode: () => CopilotPermissionMode | undefined + ) { + super(session); + this.backend.onPermissionRequest((request) => this.handlePermissionRequest(request)); + } + + private handlePermissionRequest(request: PermissionRequest): void { + const toolName = deriveToolName({ + title: request.title, + kind: request.kind, + rawInput: request.rawInput + }); + const toolInput = deriveToolInput(request); + const mode = this.getPermissionMode() ?? 'default'; + + const autoDecision = mode === 'read-only' + ? (READ_ONLY_TOOL_NAMES.has(toolName.trim().toLowerCase()) ? 'approved' : null) + : this.resolveAutoApprovalDecision(mode, toolName, request.toolCallId); + if (autoDecision) { + void this.autoApprove(request, toolName, toolInput, autoDecision); + return; + } + + this.pendingBackendRequests.set(request.id, request); + this.addPendingRequest(request.id, toolName, toolInput, { + resolve: () => {}, + reject: () => {} + }); + + logger.debug(`[Copilot] Permission request queued for ${toolName} (${request.id})`); + } + + private async autoApprove( + request: PermissionRequest, + toolName: string, + toolInput: unknown, + decision: AutoApprovalDecision + ): Promise { + const outcome = mapCopilotPermissionDecision(request, decision); + await this.backend.respondToPermission(request.sessionId, request, outcome); + + this.client.updateAgentState((currentState) => ({ + ...currentState, + completedRequests: { + ...currentState.completedRequests, + [request.id]: { + tool: toolName, + arguments: toolInput, + createdAt: Date.now(), + completedAt: Date.now(), + status: 'approved', + decision + } + } + })); + + logger.debug(`[Copilot] Auto-approved ${toolName} (${request.id}) mode=${decision}`); + } + + protected async handlePermissionResponse( + response: PermissionResponseMessage, + pending: PendingPermissionRequest + ): Promise { + const pendingRequest = this.pendingBackendRequests.get(response.id); + if (pendingRequest) { + this.pendingBackendRequests.delete(response.id); + } else { + logger.debug('[Copilot] Permission response missing backend request', response.id); + } + + const decision = response.decision ?? (response.approved ? 'approved' : 'denied'); + + if (decision === 'abort' && pendingRequest) { + await this.backend.cancelPrompt(pendingRequest.sessionId); + } + + if (pendingRequest) { + const outcome = mapCopilotPermissionDecision(pendingRequest, decision); + await this.backend.respondToPermission(pendingRequest.sessionId, pendingRequest, outcome); + } + + pending.resolve(); + + logger.debug(`[Copilot] Permission ${response.approved ? 'approved' : 'denied'} for ${pending.toolName}`); + + return { + status: response.approved ? 'approved' : 'denied', + decision, + reason: response.reason + }; + } + + protected handleMissingPendingResponse(response: PermissionResponseMessage): void { + logger.debug('[Copilot] Permission response received for unknown request', response.id); + } + + async cancelAll(reason: string): Promise { + const pending = Array.from(this.pendingBackendRequests.values()); + this.pendingBackendRequests.clear(); + + for (const request of pending) { + await this.backend.respondToPermission(request.sessionId, request, { outcome: 'cancelled' }); + } + + this.cancelPendingRequests({ + completedReason: reason, + rejectMessage: reason, + decision: 'abort' + }); + } +} diff --git a/cli/src/copilot/utils/slashCommands.test.ts b/cli/src/copilot/utils/slashCommands.test.ts new file mode 100644 index 00000000..4844f488 --- /dev/null +++ b/cli/src/copilot/utils/slashCommands.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest'; +import { resolveCopilotSlashCommand } from './slashCommands'; + +const state = { + permissionMode: 'default' as const, + model: 'gpt-5-mini', + agentMode: 'interactive' as const +}; + +describe('resolveCopilotSlashCommand', () => { + it('handles /status with agent mode', () => { + const result = resolveCopilotSlashCommand('/status', { + ...state, + agentMode: 'autopilot' + }); + expect(result).toMatchObject({ kind: 'handled' }); + if (result.kind === 'handled') { + expect(result.message).toContain('autopilot'); + expect(result.message).toContain('gpt-5-mini'); + } + }); + + it('passes /fleet through without changing agent mode', () => { + expect(resolveCopilotSlashCommand('/fleet', state)).toEqual({ kind: 'passthrough' }); + expect(resolveCopilotSlashCommand('/fleet check parser and tests', state)).toEqual({ + kind: 'passthrough' + }); + }); + + it('rejects /mode fleet as an agent mode', () => { + const result = resolveCopilotSlashCommand('/mode fleet', state); + expect(result).toMatchObject({ kind: 'handled' }); + if (result.kind === 'handled') { + expect(result.message).toContain('/fleet'); + expect(result.updates).toBeUndefined(); + } + }); + + it('preserves a task supplied with /autopilot', () => { + expect(resolveCopilotSlashCommand('/autopilot implement the fix', state)).toEqual({ + kind: 'replace', + text: 'implement the fix', + message: 'Copilot autopilot mode enabled', + updates: { agentMode: 'autopilot' } + }); + }); + + it('sets model from /model', () => { + expect(resolveCopilotSlashCommand('/model gpt-5.4', state)).toEqual({ + kind: 'handled', + message: 'Copilot model set to gpt-5.4', + updates: { model: 'gpt-5.4' } + }); + }); + + it('passes agent slash commands through to Copilot CLI', () => { + for (const command of [ + '/rubber-duck review this plan', + '/security-review', + '/research auth flow', + '/review', + '/skills list', + ]) { + expect(resolveCopilotSlashCommand(command, state)).toEqual({ kind: 'passthrough' }); + } + }); +}); diff --git a/cli/src/copilot/utils/slashCommands.ts b/cli/src/copilot/utils/slashCommands.ts new file mode 100644 index 00000000..86299696 --- /dev/null +++ b/cli/src/copilot/utils/slashCommands.ts @@ -0,0 +1,222 @@ +import { COPILOT_PERMISSION_MODES } from '@hapi/protocol/modes'; +import type { CopilotAgentMode } from '@hapi/protocol'; +import type { CopilotPermissionMode } from '@hapi/protocol/types'; +import type { SlashCommand } from '@/modules/common/slashCommands'; + +export type CopilotSlashResolution = + | { kind: 'passthrough' } + | { + kind: 'handled'; + message: string; + updates?: { + permissionMode?: CopilotPermissionMode; + model?: string | null; + agentMode?: CopilotAgentMode; + }; + } + | { + kind: 'replace'; + text: string; + message?: string; + updates?: { + permissionMode?: CopilotPermissionMode; + model?: string | null; + agentMode?: CopilotAgentMode; + }; + }; + +function resolveCopilotPermissionMode(rest: string): CopilotPermissionMode | null { + const normalized = rest.trim().toLowerCase(); + if (!normalized) return null; + if (normalized === 'default' || normalized === 'off') return 'default'; + if ((COPILOT_PERMISSION_MODES as readonly string[]).includes(normalized)) { + return normalized as CopilotPermissionMode; + } + return null; +} + +function resolveCopilotAgentMode(rest: string): CopilotAgentMode | null { + const normalized = rest.trim().toLowerCase(); + if (!normalized || normalized === 'default' || normalized === 'off' || normalized === 'interactive') { + return 'interactive'; + } + if (normalized === 'plan') return 'plan'; + if (normalized === 'autopilot') return 'autopilot'; + return null; +} + +export function resolveCopilotSlashCommand( + text: string, + state: { + commands?: readonly SlashCommand[]; + permissionMode: CopilotPermissionMode; + model?: string | null; + agentMode: CopilotAgentMode; + } +): CopilotSlashResolution { + const match = /^\s*\/([a-z0-9:_-]+)(?:\s+([\s\S]*))?$/i.exec(text); + if (!match) return { kind: 'passthrough' }; + + const command = match[1]?.toLowerCase(); + const rest = match[2]?.trim() ?? ''; + if (!command) return { kind: 'passthrough' }; + + const custom = state.commands?.find((candidate) => + candidate.source !== 'builtin' && candidate.name.toLowerCase() === command + ); + if (custom?.content) { + return { + kind: 'replace', + text: rest ? `${custom.content}\n\nUser arguments: ${rest}` : custom.content, + message: `Expanded /${custom.name}` + }; + } + + if (command === 'help') { + const lines = (state.commands ?? []) + .filter((entry) => entry.source === 'builtin') + .map((entry) => `- \`/${entry.name}\` — ${entry.description}`); + return { + kind: 'handled', + message: [ + '**Supported Copilot slash commands**', + '', + ...lines, + '', + '`/fleet` is orthogonal to agent mode (Interactive / Plan / Autopilot) and is passed through to Copilot CLI.' + ].join('\n') + }; + } + + if (command === 'status') { + return { + kind: 'handled', + message: [ + '**Copilot status**', + '', + `- agent mode: \`${state.agentMode}\``, + `- permission: \`${state.permissionMode}\``, + `- model: \`${state.model ?? 'auto'}\`` + ].join('\n') + }; + } + + if (command === 'model') { + if (!rest) { + return { kind: 'handled', message: `Copilot model: ${state.model ?? 'auto'}` }; + } + const model = rest === 'auto' || rest === 'default' ? null : rest; + return { + kind: 'handled', + message: `Copilot model set to ${model ?? 'auto'}`, + updates: { model } + }; + } + + if (command === 'permissions' || command === 'permission') { + if (!rest) { + return { kind: 'handled', message: `Copilot permission mode: ${state.permissionMode}` }; + } + const permissionMode = resolveCopilotPermissionMode(rest); + if (!permissionMode) { + return { + kind: 'handled', + message: `Unknown permission mode \`${rest}\`. Use one of: ${COPILOT_PERMISSION_MODES.join(', ')}` + }; + } + return { + kind: 'handled', + message: `Copilot permission mode set to ${permissionMode}`, + updates: { permissionMode } + }; + } + + if (command === 'plan') { + const lowerRest = rest.toLowerCase(); + if (lowerRest === 'off' || lowerRest === 'default' || lowerRest === 'exit' || lowerRest === 'disable') { + return { + kind: 'handled', + message: 'Copilot plan mode disabled', + updates: { agentMode: 'interactive' } + }; + } + if (rest) { + return { + kind: 'replace', + text: rest, + message: 'Copilot plan mode enabled', + updates: { agentMode: 'plan' } + }; + } + return { + kind: 'handled', + message: 'Copilot plan mode enabled', + updates: { agentMode: 'plan' } + }; + } + + if (command === 'autopilot') { + const lowerRest = rest.toLowerCase(); + if (lowerRest === 'off' || lowerRest === 'default' || lowerRest === 'exit' || lowerRest === 'disable') { + return { + kind: 'handled', + message: 'Copilot autopilot mode disabled', + updates: { agentMode: 'interactive' } + }; + } + if (rest) { + return { + kind: 'replace', + text: rest, + message: 'Copilot autopilot mode enabled', + updates: { agentMode: 'autopilot' } + }; + } + return { + kind: 'handled', + message: 'Copilot autopilot mode enabled', + updates: { agentMode: 'autopilot' } + }; + } + + // /fleet is not an agent mode — pass through so Copilot CLI can orchestrate + // parallel subagents alongside Interactive / Plan / Autopilot. + if (command === 'fleet') { + return { kind: 'passthrough' }; + } + + if (command === 'interactive' || command === 'default') { + return { + kind: 'handled', + message: 'Copilot interactive mode enabled', + updates: { agentMode: 'interactive' } + }; + } + + if (command === 'mode') { + if (rest.trim().toLowerCase() === 'fleet') { + return { + kind: 'handled', + message: 'Fleet is not an agent mode. Use `/fleet ` (works with Interactive, Plan, or Autopilot).' + }; + } + const agentMode = resolveCopilotAgentMode(rest); + if (!agentMode) { + return { + kind: 'handled', + message: 'Unknown agent mode. Use interactive, plan, or autopilot. For parallel subagents use `/fleet `.' + }; + } + return { + kind: 'handled', + message: `Copilot agent mode set to ${agentMode}`, + updates: { agentMode } + }; + } + + if (command === 'context' || command === 'usage' || command === 'tasks' || command === 'subagents' || command === 'agents' || command === 'delegate' || command === 'agent' || command === 'rubber-duck' || command === 'security-review' || command === 'research' || command === 'review' || command === 'skills') { + return { kind: 'passthrough' }; + } + + return { kind: 'passthrough' }; +} diff --git a/cli/src/modules/common/copilotModels.ts b/cli/src/modules/common/copilotModels.ts new file mode 100644 index 00000000..5eb73402 --- /dev/null +++ b/cli/src/modules/common/copilotModels.ts @@ -0,0 +1,268 @@ +import { spawn } from 'node:child_process'; +import { createMessageConnection, StreamMessageReader, StreamMessageWriter } from 'vscode-jsonrpc/node'; +import { asString, isObject } from '@hapi/protocol'; +import type { CopilotModelsResponse, CopilotModelSummary } from '@hapi/protocol/apiTypes'; +import { createCopilotBackend } from '@/copilot/utils/copilotBackend'; +import { getErrorMessage } from './rpcResponses'; + +export interface ListCopilotModelsForCwdRequest { + cwd?: string; +} + +export type ListCopilotModelsForCwdResponse = CopilotModelsResponse; + +interface CacheEntry { + expiresAt: number; + response: ListCopilotModelsForCwdResponse; +} + +const CACHE_TTL_MS = 60_000; +const PROBE_TIMEOUT_MS = 30_000; +const cache = new Map(); +const inflight = new Map>(); + +function normalizeAvailableModels(rawModels: unknown): CopilotModelSummary[] { + if (!Array.isArray(rawModels)) return []; + const out: CopilotModelSummary[] = []; + const seen = new Set(); + for (const entry of rawModels) { + if (!isObject(entry)) continue; + const modelId = asString(entry.modelId) + ?? asString(entry.id) + ?? asString(entry.value); + if (!modelId || seen.has(modelId)) continue; + seen.add(modelId); + const name = asString(entry.name) + ?? (modelId === 'auto' ? 'Auto' : undefined); + out.push(name ? { modelId, name } : { modelId }); + } + return out; +} + +function extractModelConfigOption(response: Record): { + currentValue: string | null; + options: unknown[]; +} | null { + if (!Array.isArray(response.configOptions)) return null; + + for (const entry of response.configOptions) { + if (!isObject(entry)) continue; + if (asString(entry.category) !== 'model') continue; + return { + currentValue: asString(entry.currentValue), + options: Array.isArray(entry.options) ? entry.options : [] + }; + } + + return null; +} + +function extractModelsFromAcpResponse(response: unknown): { + availableModels: CopilotModelSummary[]; + currentModelId: string | null; +} { + if (!isObject(response)) { + return { availableModels: [], currentModelId: null }; + } + + const meta = isObject(response._meta) ? response._meta : null; + const modelState = meta && isObject(meta.modelState) ? meta.modelState : null; + const configModelOption = extractModelConfigOption(response); + const rawModels = Array.isArray(response.availableModels) + ? response.availableModels + : modelState && Array.isArray(modelState.availableModels) + ? modelState.availableModels + : configModelOption?.options ?? null; + const rawCurrent = asString(response.currentModelId) + ?? (modelState ? asString(modelState.currentModelId) : null) + ?? configModelOption?.currentValue + ?? null; + + return { + availableModels: normalizeAvailableModels(rawModels), + currentModelId: rawCurrent + }; +} + +/** + * Copilot ACP session/new does not advertise a model catalog (only mode + + * permissions). The SDK headless protocol exposes subscription-aware models + * via `models.list` — Student plans typically return only `auto`. + */ +async function listModelsViaSdkHeadless(): Promise { + const command = process.env.COPILOT_CLI_PATH ?? 'copilot'; + const child = spawn(command, ['--headless', '--stdio', '--no-auto-update'], { + stdio: ['pipe', 'pipe', 'pipe'], + env: process.env + }); + + if (!child.stdin || !child.stdout) { + child.kill(); + throw new Error('Failed to open Copilot headless stdio pipes'); + } + + const connection = createMessageConnection( + new StreamMessageReader(child.stdout), + new StreamMessageWriter(child.stdin) + ); + connection.listen(); + + const exitPromise = new Promise((_, reject) => { + child.once('exit', (code) => { + reject(new Error(`Copilot headless exited with code ${code ?? 'unknown'}`)); + }); + child.once('error', (error) => { + reject(error); + }); + }); + + try { + const result = await Promise.race([ + connection.sendRequest('models.list', {}), + exitPromise, + new Promise((_, reject) => { + setTimeout(() => reject(new Error('Timed out listing Copilot models')), PROBE_TIMEOUT_MS); + }) + ]); + const models = isObject(result) && Array.isArray(result.models) + ? result.models + : Array.isArray(result) + ? result + : []; + return normalizeAvailableModels(models); + } finally { + try { + connection.dispose(); + } catch { + // ignore + } + if (!child.killed) { + child.kill(); + } + } +} + +async function listModelsViaAcpProbe(cwd: string): Promise<{ + availableModels: CopilotModelSummary[]; + currentModelId: string | null; +}> { + const backend = createCopilotBackend(); + try { + await backend.initialize(); + const sessionId = await backend.newSession({ cwd, mcpServers: [] }); + const metadata = backend.getSessionModelsMetadata(sessionId); + const modelOption = backend.getConfigOptionByCategory(sessionId, 'model'); + return extractModelsFromAcpResponse({ + availableModels: metadata?.availableModels, + currentModelId: metadata?.currentModelId, + configOptions: modelOption ? [{ + category: 'model', + currentValue: modelOption.currentValue, + options: modelOption.options + }] : [] + }); + } finally { + await backend.disconnect().catch(() => {}); + } +} + +async function runCopilotProbe(cwd: string): Promise { + try { + // Prefer SDK headless models.list — subscription-aware (Student → auto only). + const sdkModels = await listModelsViaSdkHeadless(); + if (sdkModels.length > 0) { + return { + success: true, + availableModels: sdkModels, + currentModelId: sdkModels.find((model) => model.modelId === 'auto')?.modelId + ?? sdkModels[0]?.modelId + ?? null + }; + } + } catch { + // Fall through to ACP probe; never invent a static catalog. + } + + try { + const acp = await listModelsViaAcpProbe(cwd || process.cwd()); + return { + success: true, + availableModels: acp.availableModels, + currentModelId: acp.currentModelId + }; + } catch (error) { + return { + success: false, + error: getErrorMessage(error, 'Failed to list Copilot models'), + availableModels: [], + currentModelId: null + }; + } +} + +export async function listCopilotModelsForCwd(cwd: string): Promise { + const key = cwd || process.cwd(); + const cached = cache.get(key); + if (cached && cached.expiresAt > Date.now()) { + return cached.response; + } + + const pending = inflight.get(key); + if (pending) { + return pending; + } + + const promise = runCopilotProbe(key).then((response) => { + cache.set(key, { expiresAt: Date.now() + CACHE_TTL_MS, response }); + inflight.delete(key); + return response; + }).catch((error) => { + inflight.delete(key); + throw error; + }); + + inflight.set(key, promise); + return promise; +} + +export async function buildCopilotModelsResponseFromBackend( + sessionId: string, + backend: { + getSessionModelsMetadata: (id: string) => { + availableModels: CopilotModelSummary[]; + currentModelId: string | null; + } | undefined; + getConfigOptionByCategory: (id: string, category: string) => { + currentValue?: string; + options?: Array<{ value: string; name?: string }>; + } | undefined; + }, + cwd?: string +): Promise { + const metadata = backend.getSessionModelsMetadata(sessionId); + const modelOption = backend.getConfigOptionByCategory(sessionId, 'model'); + const parsed = extractModelsFromAcpResponse({ + availableModels: metadata?.availableModels, + currentModelId: metadata?.currentModelId, + configOptions: modelOption ? [{ + category: 'model', + currentValue: modelOption.currentValue, + options: modelOption.options + }] : [] + }); + + if (parsed.availableModels.length > 0) { + return { + success: true, + availableModels: parsed.availableModels, + currentModelId: parsed.currentModelId ?? metadata?.currentModelId ?? null + }; + } + + // ACP has no catalog — reuse subscription-aware SDK list (cached). + const response = await listCopilotModelsForCwd(cwd ?? process.cwd()); + return { + ...response, + currentModelId: parsed.currentModelId ?? response.currentModelId ?? null + }; +} diff --git a/cli/src/modules/common/handlers/copilotModels.ts b/cli/src/modules/common/handlers/copilotModels.ts new file mode 100644 index 00000000..39e593e3 --- /dev/null +++ b/cli/src/modules/common/handlers/copilotModels.ts @@ -0,0 +1,23 @@ +import { logger } from '@/ui/logger'; +import { RPC_METHODS } from '@hapi/protocol/rpcMethods'; +import type { RpcHandlerManager } from '@/api/rpc/RpcHandlerManager'; +import { + listCopilotModelsForCwd, + type ListCopilotModelsForCwdRequest, + type ListCopilotModelsForCwdResponse +} from '../copilotModels'; +import { getErrorMessage, rpcError } from '../rpcResponses'; + +export function registerCopilotModelHandlers(rpcHandlerManager: RpcHandlerManager): void { + rpcHandlerManager.registerHandler( + RPC_METHODS.ListCopilotModelsForCwd, + async (data) => { + try { + return await listCopilotModelsForCwd(typeof data?.cwd === 'string' ? data.cwd : ''); + } catch (error) { + logger.debug('Failed to list Copilot models:', error); + return rpcError(getErrorMessage(error, 'Failed to list Copilot models')); + } + } + ); +} diff --git a/cli/src/modules/common/registerCommonHandlers.ts b/cli/src/modules/common/registerCommonHandlers.ts index 4b484349..b4d61245 100644 --- a/cli/src/modules/common/registerCommonHandlers.ts +++ b/cli/src/modules/common/registerCommonHandlers.ts @@ -4,6 +4,7 @@ import { registerCodexModelHandlers } from './handlers/codexModels' import { registerCursorModelHandlers } from './handlers/cursorModels' import { registerOpencodeModelHandlers } from './handlers/opencodeModels' import { registerGrokModelHandlers } from './handlers/grokModels' +import { registerCopilotModelHandlers } from './handlers/copilotModels' import { registerDirectoryHandlers } from './handlers/directories' import { registerDifftasticHandlers } from './handlers/difftastic' import { registerFileHandlers } from './handlers/files' @@ -19,6 +20,7 @@ export function registerCommonHandlers(rpcHandlerManager: RpcHandlerManager, wor registerCursorModelHandlers(rpcHandlerManager) registerOpencodeModelHandlers(rpcHandlerManager) registerGrokModelHandlers(rpcHandlerManager) + registerCopilotModelHandlers(rpcHandlerManager) registerFileHandlers(rpcHandlerManager, workingDirectory) registerDirectoryHandlers(rpcHandlerManager, workingDirectory) registerRipgrepHandlers(rpcHandlerManager, workingDirectory) diff --git a/cli/src/modules/common/rpcTypes.ts b/cli/src/modules/common/rpcTypes.ts index e1a6e219..038829a1 100644 --- a/cli/src/modules/common/rpcTypes.ts +++ b/cli/src/modules/common/rpcTypes.ts @@ -1,4 +1,5 @@ import type { AgentFlavor } from '@hapi/protocol' +import type { CopilotAgentMode } from '@hapi/protocol' export interface SpawnSessionOptions { machineId?: string @@ -15,6 +16,7 @@ export interface SpawnSessionOptions { permissionMode?: string serviceTier?: string collaborationMode?: 'default' | 'plan' + copilotAgentMode?: CopilotAgentMode token?: string sessionType?: 'simple' | 'worktree' worktreeName?: string diff --git a/cli/src/modules/common/skills.test.ts b/cli/src/modules/common/skills.test.ts index 81a103de..5057ecf0 100644 --- a/cli/src/modules/common/skills.test.ts +++ b/cli/src/modules/common/skills.test.ts @@ -122,6 +122,18 @@ describe('listSkills', () => { expect(skills.map((skill) => skill.name)).toEqual(['grok-project', 'grok-user', 'shared']) }) + it('lists Copilot user and project skills alongside shared .agents skills', async () => { + const repoRoot = join(sandboxDir, 'copilot-repo') + await mkdir(join(repoRoot, '.git'), { recursive: true }) + await writeSkill(join(homeDir, '.copilot', 'skills', 'copilot-user'), 'copilot-user', 'Copilot user skill') + await writeSkill(join(homeDir, '.agents', 'skills', 'shared'), 'shared', 'Shared skill') + await writeSkill(join(repoRoot, '.github', 'skills', 'github-skill'), 'github-skill', 'GitHub skill') + + const skills = await listSkills(repoRoot, { flavor: 'copilot' }) + + expect(skills.map((skill) => skill.name)).toEqual(['copilot-user', 'github-skill', 'shared']) + }) + it('scopes user skills to the requested flavor', async () => { await writeSkill(join(homeDir, '.agents', 'skills', 'shared'), 'shared', 'Shared skill') await writeSkill(join(homeDir, '.claude', 'skills', 'claude-only'), 'claude-only', 'Claude skill') diff --git a/cli/src/modules/common/skills.ts b/cli/src/modules/common/skills.ts index 9c421672..14c6d27d 100644 --- a/cli/src/modules/common/skills.ts +++ b/cli/src/modules/common/skills.ts @@ -73,6 +73,9 @@ function getUserSkillsRoots(flavor?: string): string[] { case 'grok': roots.push(join(getAgentConfigDir(flavor), 'skills')); break; + case 'copilot': + roots.push(join(getAgentConfigDir(flavor), 'skills')); + break; } return roots; } @@ -93,6 +96,10 @@ function getProjectSkillsRoots(directory: string, flavor?: string): string[] { case 'grok': roots.push(join(directory, '.grok', 'skills')); break; + case 'copilot': + roots.push(join(directory, '.copilot', 'skills')); + roots.push(join(directory, '.github', 'skills')); + break; } return roots; } diff --git a/cli/src/modules/common/slashCommands.test.ts b/cli/src/modules/common/slashCommands.test.ts index 2b4171c5..a19c2048 100644 --- a/cli/src/modules/common/slashCommands.test.ts +++ b/cli/src/modules/common/slashCommands.test.ts @@ -8,7 +8,9 @@ describe('listSlashCommands', () => { const originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR const originalCodexHome = process.env.CODEX_HOME const originalXdgConfigHome = process.env.XDG_CONFIG_HOME + const originalHome = process.env.HOME let sandboxDir: string + let homeDir: string let claudeConfigDir: string let codexHome: string let xdgConfigHome: string @@ -17,16 +19,19 @@ describe('listSlashCommands', () => { beforeEach(async () => { sandboxDir = await mkdtemp(join(tmpdir(), 'hapi-slash-commands-')) + homeDir = join(sandboxDir, 'home') claudeConfigDir = join(sandboxDir, 'global-claude') codexHome = join(sandboxDir, 'global-codex') xdgConfigHome = join(sandboxDir, 'xdg-config') opencodeUserDir = join(xdgConfigHome, 'opencode', 'command') projectDir = join(sandboxDir, 'project') + process.env.HOME = homeDir process.env.CLAUDE_CONFIG_DIR = claudeConfigDir process.env.CODEX_HOME = codexHome process.env.XDG_CONFIG_HOME = xdgConfigHome + await mkdir(homeDir, { recursive: true }) await mkdir(join(claudeConfigDir, 'commands'), { recursive: true }) await mkdir(join(codexHome, 'prompts'), { recursive: true }) await mkdir(opencodeUserDir, { recursive: true }) @@ -36,6 +41,11 @@ describe('listSlashCommands', () => { }) afterEach(async () => { + if (originalHome === undefined) { + delete process.env.HOME + } else { + process.env.HOME = originalHome + } if (originalClaudeConfigDir === undefined) { delete process.env.CLAUDE_CONFIG_DIR } else { @@ -280,4 +290,21 @@ describe('listSlashCommands', () => { content: 'Local body', }) }) + + it('exposes Copilot agent builtins (skills stay on $ autocomplete)', async () => { + const commands = await listSlashCommands('copilot', projectDir) + const names = commands.map((command) => command.name) + + expect(names).toEqual(expect.arrayContaining([ + 'rubber-duck', + 'security-review', + 'research', + 'review', + 'skills', + 'fleet', + ])) + expect(names).not.toContain('fix-issue') + expect(commands.find((command) => command.name === 'rubber-duck')?.source).toBe('builtin') + expect(commands.find((command) => command.name === 'plan')?.source).toBe('builtin') + }) }) diff --git a/cli/src/runner/run.ts b/cli/src/runner/run.ts index 59bf22ef..aaf665e7 100644 --- a/cli/src/runner/run.ts +++ b/cli/src/runner/run.ts @@ -1463,7 +1463,9 @@ export function buildCliArgs( ? 'grok' : agent === 'kimi' ? 'kimi' - : agent === 'opencode' + : agent === 'copilot' + ? 'copilot' + : agent === 'opencode' ? 'opencode' : agent === 'pi' ? 'pi' @@ -1518,6 +1520,9 @@ export function buildCliArgs( if (options.collaborationMode && options.collaborationMode !== 'default' && agent === 'codex') { args.push('--collaboration-mode', options.collaborationMode); } + if (options.copilotAgentMode && options.copilotAgentMode !== 'interactive' && agent === 'copilot') { + args.push('--copilot-agent-mode', options.copilotAgentMode); + } // Pi RPC mode has no permission switching; never pass these flags to it // (the Pi parser rejects --permission-mode and ignores --yolo). if (agent !== 'pi') { diff --git a/cli/src/ui/ink/CopilotDisplay.tsx b/cli/src/ui/ink/CopilotDisplay.tsx new file mode 100644 index 00000000..343dbb79 --- /dev/null +++ b/cli/src/ui/ink/CopilotDisplay.tsx @@ -0,0 +1,196 @@ +import React, { useEffect, useState } from 'react'; +import { Box, Text, useStdout } from 'ink'; +import { MessageBuffer, type BufferedMessage } from './messageBuffer'; +import { useSwitchControls } from './useSwitchControls'; + +interface CopilotDisplayProps { + messageBuffer: MessageBuffer; + logPath?: string; + onExit?: () => void; + onSwitchToLocal?: () => void; +} + +function extractTag(messages: BufferedMessage[], tag: 'MODEL' | 'MODE' | 'AGENT_MODE'): string | null { + const prefix = `[${tag}:`; + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index]; + if (message.type !== 'system') { + continue; + } + if (!message.content.startsWith(prefix)) { + continue; + } + const match = message.content.match(/\[\w+:(.+?)\]/); + if (match && match[1]) { + return match[1]; + } + } + return null; +} + +export const CopilotDisplay: React.FC = ({ + messageBuffer, + logPath, + onExit, + onSwitchToLocal +}) => { + const [messages, setMessages] = useState([]); + const [model, setModel] = useState(null); + const [permissionMode, setPermissionMode] = useState(null); + const [agentMode, setAgentMode] = useState(null); + const { confirmationMode, actionInProgress } = useSwitchControls({ + onExit, + onSwitch: onSwitchToLocal + }); + const { stdout } = useStdout(); + const terminalWidth = stdout.columns || 80; + const terminalHeight = stdout.rows || 24; + + useEffect(() => { + setMessages(messageBuffer.getMessages()); + + const unsubscribe = messageBuffer.onUpdate((newMessages) => { + setMessages(newMessages); + const nextModel = extractTag(newMessages, 'MODEL'); + if (nextModel) { + setModel(nextModel); + } + const nextMode = extractTag(newMessages, 'MODE'); + if (nextMode) { + setPermissionMode(nextMode); + } + const nextAgentMode = extractTag(newMessages, 'AGENT_MODE'); + if (nextAgentMode) { + setAgentMode(nextAgentMode); + } + }); + + return () => { + unsubscribe(); + }; + }, [messageBuffer]); + + const getMessageColor = (type: BufferedMessage['type']): string => { + switch (type) { + case 'user': return 'magenta'; + case 'assistant': return 'cyan'; + case 'system': return 'blue'; + case 'tool': return 'yellow'; + case 'result': return 'green'; + case 'status': return 'gray'; + default: return 'white'; + } + }; + + const formatMessage = (msg: BufferedMessage): string => { + const lines = msg.content.split('\n'); + const maxLineLength = Math.max(1, terminalWidth - 10); + return lines.map(line => { + if (line.length <= maxLineLength) return line; + const chunks: string[] = []; + for (let i = 0; i < line.length; i += maxLineLength) { + chunks.push(line.slice(i, i + maxLineLength)); + } + return chunks.join('\n'); + }).join('\n'); + }; + + const visibleMessages = messages.filter((msg) => { + if (msg.type === 'system' && msg.content.startsWith('[MODEL:')) { + return false; + } + if (msg.type === 'system' && msg.content.startsWith('[MODE:')) { + return false; + } + if (msg.type === 'system' && msg.content.startsWith('[AGENT_MODE:')) { + return false; + } + return true; + }); + + return ( + + + + Copilot Agent Messages + {'-'.repeat(Math.min(terminalWidth - 4, 60))} + + + + {visibleMessages.length === 0 ? ( + Waiting for messages... + ) : ( + visibleMessages + .slice(-Math.max(1, terminalHeight - 10)) + .map((msg) => ( + + + {formatMessage(msg)} + + + )) + )} + + + + + + {actionInProgress === 'exiting' ? ( + + Exiting agent... + + ) : actionInProgress === 'switching' ? ( + + Switching to local mode... + + ) : confirmationMode === 'exit' ? ( + + Press Ctrl-C again to exit the agent + + ) : confirmationMode === 'switch' ? ( + + Press space again to switch to local mode + + ) : ( + + Copilot running {onSwitchToLocal ? '(Space to switch to local, Ctrl-C to exit)' : '(Ctrl-C to exit)'} + + )} + {(model || permissionMode || agentMode) && ( + + {model ? `Model: ${model}` : 'Model: auto'} + {agentMode ? ` | Mode: ${agentMode}` : ''} + {permissionMode ? ` | Permission: ${permissionMode}` : ''} + + )} + {process.env.DEBUG && logPath && ( + + Debug logs: {logPath} + + )} + + + + ); +}; diff --git a/hub/src/socket/handlers/cli/sessionHandlers.ts b/hub/src/socket/handlers/cli/sessionHandlers.ts index 39f34356..5906ba0f 100644 --- a/hub/src/socket/handlers/cli/sessionHandlers.ts +++ b/hub/src/socket/handlers/cli/sessionHandlers.ts @@ -2,6 +2,7 @@ import type { ClientToServerEvents } from '@hapi/protocol' import { z } from 'zod' import { randomUUID } from 'node:crypto' import type { CodexCollaborationMode, PermissionMode } from '@hapi/protocol/types' +import type { CopilotAgentMode } from '@hapi/protocol' import { isRedundantGoalStatusEventContent } from '@hapi/protocol/messages' import type { Store, StoredSession } from '../../../store' import type { SyncEvent } from '../../../sync/syncEngine' @@ -24,6 +25,7 @@ type SessionAlivePayload = { effort?: string | null serviceTier?: string | null collaborationMode?: CodexCollaborationMode + copilotAgentMode?: CopilotAgentMode } type SessionEndPayload = { diff --git a/hub/src/store/sessions.test.ts b/hub/src/store/sessions.test.ts index 5fea3be8..6d0c020a 100644 --- a/hub/src/store/sessions.test.ts +++ b/hub/src/store/sessions.test.ts @@ -175,6 +175,7 @@ describe('updateSessionMetadata: protocol resume token preservation', () => { ['grokSessionId', 'grok-thread-x'], ['cursorSessionId', 'cursor-thread-x'], ['kimiSessionId', 'kimi-thread-x'], + ['copilotSessionId', 'copilot-thread-x'], ['piSessionId', 'pi-thread-x'] ])('preserves %s across an archive metadata replacement', (field, value) => { const store = makeStore() diff --git a/hub/src/store/sessions.ts b/hub/src/store/sessions.ts index c6bd11ad..69fc64bc 100644 --- a/hub/src/store/sessions.ts +++ b/hub/src/store/sessions.ts @@ -61,6 +61,7 @@ const SIMPLE_RESUME_TOKENS = [ 'grokSessionId', 'cursorSessionId', 'kimiSessionId', + 'copilotSessionId', 'piSessionId' ] as const diff --git a/hub/src/sync/opencodeClear.test.ts b/hub/src/sync/opencodeClear.test.ts index 7f1c75ee..0e936ac6 100644 --- a/hub/src/sync/opencodeClear.test.ts +++ b/hub/src/sync/opencodeClear.test.ts @@ -629,6 +629,7 @@ describe('SyncEngine.clearOpenCodeSession', () => { 'yolo', undefined, replacementSessionId, + undefined, undefined ) expect(engine.getSessionByNamespace(replacementSessionId, 'default')?.metadata).toMatchObject({ diff --git a/hub/src/sync/rpcGateway.test.ts b/hub/src/sync/rpcGateway.test.ts index 50121366..d565a78b 100644 --- a/hub/src/sync/rpcGateway.test.ts +++ b/hub/src/sync/rpcGateway.test.ts @@ -75,6 +75,15 @@ describe('RpcGateway RPC timeouts', () => { expect(timeouts).toEqual([120_000]) }) + it('uses an extended RPC timeout when listing Copilot models', async () => { + const { gateway, timeouts } = createGateway() + + await gateway.listCopilotModelsForCwd('machine-1', '/workspace') + await gateway.listCopilotModelsForSession('session-1') + + expect(timeouts).toEqual([120_000, 120_000]) + }) + it('forwards the recorded session owner home to the Cursor store probe', async () => { const { gateway, calls } = createGateway() diff --git a/hub/src/sync/rpcGateway.ts b/hub/src/sync/rpcGateway.ts index d38465c1..3842d18d 100644 --- a/hub/src/sync/rpcGateway.ts +++ b/hub/src/sync/rpcGateway.ts @@ -1,4 +1,4 @@ -import type { AgentFlavor, CodexCollaborationMode, PermissionMode } from '@hapi/protocol/types' +import type { AgentFlavor, CodexCollaborationMode, CopilotAgentMode, PermissionMode } from '@hapi/protocol/types' import { RPC_METHODS } from '@hapi/protocol/rpcMethods' import { ArchiveCodexSessionRpcResponseSchema, @@ -16,6 +16,7 @@ import type { DirectoryEntry, FileReadResponse, GeneratedImageResponse, + CopilotModelsResponse, GrokModelsResponse, GrokReasoningEffortResponse, ListDirectoryResponse, @@ -75,6 +76,7 @@ export type RpcCursorChatStoreStatus = CursorChatStoreStatus export type RpcOpencodeModel = OpencodeModelSummary export type RpcListOpencodeModelsResponse = OpencodeModelsResponse export type RpcListGrokModelsResponse = GrokModelsResponse +export type RpcListCopilotModelsResponse = CopilotModelsResponse export type RpcListGrokReasoningEffortOptionsResponse = GrokReasoningEffortResponse export type RpcListOpencodeReasoningEffortOptionsResponse = OpencodeReasoningEffortResponse @@ -131,6 +133,7 @@ export class RpcGateway { modelReasoningEffort?: string | null effort?: string | null collaborationMode?: CodexCollaborationMode + copilotAgentMode?: CopilotAgentMode } ): Promise { return await this.sessionRpc(sessionId, RPC_METHODS.SetSessionConfig, config) @@ -166,6 +169,7 @@ export class RpcGateway { serviceTier?: string, existingSessionId?: string, collaborationMode?: CodexCollaborationMode, + copilotAgentMode?: CopilotAgentMode, forkSession?: boolean ): Promise<{ type: 'success'; sessionId: string } | { type: 'error'; message: string }> { try { @@ -188,6 +192,7 @@ export class RpcGateway { existingSessionId, sessionId: existingSessionId, collaborationMode, + copilotAgentMode, forkSession: forkSession === true } ) @@ -362,6 +367,24 @@ export class RpcGateway { return await this.sessionRpc(sessionId, RPC_METHODS.ListGrokReasoningEffortOptions, {}) as RpcListGrokReasoningEffortOptionsResponse } + async listCopilotModelsForCwd(machineId: string, cwd: string): Promise { + return await this.machineRpc( + machineId, + RPC_METHODS.ListCopilotModelsForCwd, + { cwd }, + MODEL_LIST_RPC_TIMEOUT_MS + ) as RpcListCopilotModelsResponse + } + + async listCopilotModelsForSession(sessionId: string): Promise { + return await this.sessionRpc( + sessionId, + RPC_METHODS.ListCopilotModels, + {}, + MODEL_LIST_RPC_TIMEOUT_MS + ) as RpcListCopilotModelsResponse + } + /** Generic Pi RPC call — routes all Pi-specific session RPCs through * a single entry point instead of per-method wrappers. */ async callPiRpc(sessionId: string, method: string, params?: Record, timeoutMs?: number): Promise { diff --git a/hub/src/sync/sessionCache.ts b/hub/src/sync/sessionCache.ts index 2ad31a22..79b878b6 100644 --- a/hub/src/sync/sessionCache.ts +++ b/hub/src/sync/sessionCache.ts @@ -1,5 +1,5 @@ import { AgentStateSchema, MetadataSchema, TeamStateSchema } from '@hapi/protocol/schemas' -import type { CodexCollaborationMode, PermissionMode, Session, SessionPatch } from '@hapi/protocol/types' +import type { CodexCollaborationMode, CopilotAgentMode, PermissionMode, Session, SessionPatch } from '@hapi/protocol/types' import type { Store } from '../store' import { clampAliveTime } from './aliveTime' import { EventPublisher } from './eventPublisher' @@ -12,7 +12,7 @@ const QUEUED_MESSAGE_THINKING_GRACE_MS = 15_000 // snapshot. Cap retries so genuine concurrent contention still surfaces to the // HTTP caller as 409 instead of spinning forever. const METADATA_RETRY_ATTEMPTS = 5 -type RuntimeConfigKey = 'permissionMode' | 'model' | 'modelReasoningEffort' | 'effort' | 'serviceTier' | 'collaborationMode' +type RuntimeConfigKey = 'permissionMode' | 'model' | 'modelReasoningEffort' | 'effort' | 'serviceTier' | 'collaborationMode' | 'copilotAgentMode' export class SessionCache { private readonly sessions: Map = new Map() @@ -202,7 +202,8 @@ export class SessionCache { effort: stored.effort, serviceTier: stored.serviceTier, permissionMode: existing?.permissionMode ?? metadata?.preferredPermissionMode, - collaborationMode: existing?.collaborationMode + collaborationMode: existing?.collaborationMode, + copilotAgentMode: existing?.copilotAgentMode ?? metadata?.preferredCopilotAgentMode } this.sessions.set(sessionId, session) @@ -254,6 +255,7 @@ export class SessionCache { effort?: string | null serviceTier?: string | null collaborationMode?: CodexCollaborationMode + copilotAgentMode?: CopilotAgentMode }): void { const t = clampAliveTime(payload.time) if (!t) return @@ -269,6 +271,7 @@ export class SessionCache { const previousEffort = session.effort const previousServiceTier = session.serviceTier const previousCollaborationMode = session.collaborationMode + const previousCopilotAgentMode = session.copilotAgentMode const pendingThinkingUntil = this.pendingThinkingUntilBySessionId.get(session.id) ?? 0 const requestedThinking = Boolean(payload.thinking) const hubNow = Date.now() @@ -320,6 +323,10 @@ export class SessionCache { if (payload.collaborationMode !== undefined && !this.isStaleRuntimeKeepAlive(session.id, 'collaborationMode', t)) { session.collaborationMode = payload.collaborationMode } + if (payload.copilotAgentMode !== undefined && !this.isStaleRuntimeKeepAlive(session.id, 'copilotAgentMode', t)) { + session.copilotAgentMode = payload.copilotAgentMode + this.persistPreferredCopilotAgentMode(session, payload.copilotAgentMode) + } const now = Date.now() const lastBroadcastAt = this.lastBroadcastAtBySessionId.get(session.id) ?? 0 @@ -329,6 +336,7 @@ export class SessionCache { || previousEffort !== session.effort || previousServiceTier !== session.serviceTier || previousCollaborationMode !== session.collaborationMode + || previousCopilotAgentMode !== session.copilotAgentMode const shouldBroadcast = (!wasActive && session.active) || (wasThinking !== session.thinking) || modeChanged @@ -348,7 +356,8 @@ export class SessionCache { modelReasoningEffort: session.modelReasoningEffort, effort: session.effort, serviceTier: session.serviceTier, - collaborationMode: session.collaborationMode + collaborationMode: session.collaborationMode, + copilotAgentMode: session.copilotAgentMode } satisfies SessionPatch }) } @@ -531,6 +540,7 @@ export class SessionCache { effort?: string | null serviceTier?: string | null collaborationMode?: CodexCollaborationMode + copilotAgentMode?: CopilotAgentMode } ): void { const session = this.sessions.get(sessionId) ?? this.refreshSession(sessionId) @@ -608,6 +618,11 @@ export class SessionCache { session.collaborationMode = config.collaborationMode this.markRuntimeConfigUpdated(sessionId, 'collaborationMode', appliedAt) } + if (config.copilotAgentMode !== undefined) { + session.copilotAgentMode = config.copilotAgentMode + this.persistPreferredCopilotAgentMode(session, config.copilotAgentMode) + this.markRuntimeConfigUpdated(sessionId, 'copilotAgentMode', appliedAt) + } this.publisher.emit({ type: 'session-updated', sessionId, data: session }) } @@ -1167,6 +1182,10 @@ export class SessionCache { merged.preferredPermissionMode = oldObj.preferredPermissionMode changed = true } + if (typeof oldObj.preferredCopilotAgentMode === 'string' && typeof newObj.preferredCopilotAgentMode !== 'string') { + merged.preferredCopilotAgentMode = oldObj.preferredCopilotAgentMode + changed = true + } return changed ? merged : newMetadata } @@ -1199,6 +1218,34 @@ export class SessionCache { session.metadataVersion = result.version } + private persistPreferredCopilotAgentMode(session: Session, copilotAgentMode: CopilotAgentMode): void { + const currentMetadata = session.metadata + if (!currentMetadata || currentMetadata.preferredCopilotAgentMode === copilotAgentMode) { + return + } + + const nextMetadata = { ...currentMetadata, preferredCopilotAgentMode: copilotAgentMode } + const result = this.store.sessions.updateSessionMetadata( + session.id, + nextMetadata, + session.metadataVersion, + session.namespace, + { touchUpdatedAt: false } + ) + + if (result.result === 'error') { + return + } + + const parsed = MetadataSchema.safeParse(result.value) + if (!parsed.success) { + return + } + + session.metadata = parsed.data + session.metadataVersion = result.version + } + private persistPiSelectedModel(session: Session, piSelected: { provider: string; modelId: string } | null): void { const currentMetadata = session.metadata if (!currentMetadata || currentMetadata.piSelectedModel === piSelected) { @@ -1252,7 +1299,7 @@ export class SessionCache { private extractAgentSessionId( metadata: NonNullable - ): { field: 'codexSessionId' | 'claudeSessionId' | 'geminiSessionId' | 'opencodeSessionId' | 'grokSessionId' | 'cursorSessionId' | 'piSessionId'; value: string } | null { + ): { field: 'codexSessionId' | 'claudeSessionId' | 'geminiSessionId' | 'opencodeSessionId' | 'grokSessionId' | 'cursorSessionId' | 'piSessionId' | 'copilotSessionId'; value: string } | null { if (metadata.codexSessionId) return { field: 'codexSessionId', value: metadata.codexSessionId } if (metadata.claudeSessionId) return { field: 'claudeSessionId', value: metadata.claudeSessionId } if (metadata.geminiSessionId) return { field: 'geminiSessionId', value: metadata.geminiSessionId } @@ -1260,6 +1307,7 @@ export class SessionCache { if (metadata.grokSessionId) return { field: 'grokSessionId', value: metadata.grokSessionId } if (metadata.cursorSessionId) return { field: 'cursorSessionId', value: metadata.cursorSessionId } if (metadata.piSessionId) return { field: 'piSessionId', value: metadata.piSessionId } + if (metadata.copilotSessionId) return { field: 'copilotSessionId', value: metadata.copilotSessionId } return null } diff --git a/hub/src/sync/sessionModel.test.ts b/hub/src/sync/sessionModel.test.ts index c3cf660b..4e276d81 100644 --- a/hub/src/sync/sessionModel.test.ts +++ b/hub/src/sync/sessionModel.test.ts @@ -1693,6 +1693,108 @@ describe('session model', () => { } }) + it('passes stored Copilot agent mode when respawning a resumed Copilot session', async () => { + const store = new Store(':memory:') + const engine = new SyncEngine( + store, + {} as never, + new RpcRegistry(), + { broadcast() {} } as never + ) + + try { + const session = engine.getOrCreateSession( + 'session-copilot-agent-mode-resume', + { + path: '/tmp/project', + host: 'localhost', + machineId: 'machine-1', + flavor: 'copilot', + copilotSessionId: 'copilot-thread-1' + }, + null, + 'default', + 'gpt-5' + ) + await engine.applySessionConfig(session.id, { copilotAgentMode: 'plan' }) + engine.getOrCreateMachine( + 'machine-1', + { host: 'localhost', platform: 'linux', happyCliVersion: '0.1.0' }, + null, + 'default' + ) + engine.handleMachineAlive({ machineId: 'machine-1', time: Date.now() }) + + let capturedCopilotAgentMode: string | undefined + ;(engine as any).rpcGateway.spawnSession = async ( + _machineId: string, + _directory: string, + _agent: string, + _model?: string, + _modelReasoningEffort?: string, + _yolo?: boolean, + _sessionType?: string, + _worktreeName?: string, + _resumeSessionId?: string, + _effort?: string, + _permissionMode?: string, + _serviceTier?: string, + _existingSessionId?: string, + _collaborationMode?: string, + copilotAgentMode?: string + ) => { + capturedCopilotAgentMode = copilotAgentMode + return { type: 'success', sessionId: session.id } + } + ;(engine as any).waitForSessionActive = async () => true + + const result = await engine.resumeSession(session.id, 'default') + + expect(result).toEqual({ type: 'success', sessionId: session.id }) + expect(capturedCopilotAgentMode).toBe('plan') + } finally { + engine.stop() + } + }) + + it('restores the Copilot agent mode from metadata after a hub restart', async () => { + const store = new Store(':memory:') + const firstEngine = new SyncEngine( + store, + {} as never, + new RpcRegistry(), + { broadcast() {} } as never + ) + const session = firstEngine.getOrCreateSession( + 'session-copilot-agent-mode-restart', + { + path: '/tmp/project', + host: 'localhost', + flavor: 'copilot', + copilotSessionId: 'copilot-thread-1' + }, + null, + 'default' + ) + await firstEngine.applySessionConfig(session.id, { copilotAgentMode: 'autopilot' }) + firstEngine.stop() + + const restartedEngine = new SyncEngine( + store, + {} as never, + new RpcRegistry(), + { broadcast() {} } as never + ) + try { + expect(restartedEngine.getSession(session.id)?.copilotAgentMode).toBe('autopilot') + expect(store.sessions.getSession(session.id)?.metadata).toEqual(expect.objectContaining({ + preferredCopilotAgentMode: 'autopilot' + })) + } finally { + restartedEngine.stop() + } + }) + it('passes the cached permissionMode when respawning a resumed session', async () => { const store = new Store(':memory:') const engine = new SyncEngine( @@ -3330,6 +3432,30 @@ describe('session model', () => { expect(messages.length).toBeGreaterThanOrEqual(1) }) + it('merges duplicate when copilotSessionId collides', async () => { + const store = new Store(':memory:') + const events: SyncEvent[] = [] + const cache = new SessionCache(store, createPublisher(events)) + + const s1 = cache.getOrCreateSession( + 'copilot-tag-1', + { path: '/tmp/project', host: 'localhost', flavor: 'copilot', copilotSessionId: 'copilot-thread-X' }, + null, + 'default' + ) + const s2 = cache.getOrCreateSession( + 'copilot-tag-2', + { path: '/tmp/project', host: 'localhost', flavor: 'copilot', copilotSessionId: 'copilot-thread-X' }, + null, + 'default' + ) + + await cache.deduplicateByAgentSessionId(s2.id) + + expect(cache.getSession(s1.id)).toBeUndefined() + expect(cache.getSession(s2.id)).toBeDefined() + }) + it('preserves sessions with different agent session IDs', async () => { const store = new Store(':memory:') const events: SyncEvent[] = [] diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index e41a0176..9c7f52cb 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -9,7 +9,7 @@ import { isKnownFlavor, type LocalResumeTarget, type ResumableSession, type SessionEndReason } from '@hapi/protocol' import type { CursorChatStoreStatus, CursorMigrateOutcome, CursorMigrateToAcpRequest, MessagesResponse, QueuedStateResponse, SlashCommandsResponse } from '@hapi/protocol/apiTypes' -import type { AgentFlavor, CodexCollaborationMode, DecryptedMessage, PermissionMode, Session, SyncEvent } from '@hapi/protocol/types' +import type { AgentFlavor, CodexCollaborationMode, CopilotAgentMode, DecryptedMessage, PermissionMode, Session, SyncEvent } from '@hapi/protocol/types' import { unwrapRoleWrappedRecordEnvelope } from '@hapi/protocol/messages' import type { Server } from 'socket.io' import { randomUUID } from 'node:crypto' @@ -37,6 +37,7 @@ import { type RpcListCursorModelsResponse, type RpcListOpencodeModelsResponse, type RpcListGrokModelsResponse, + type RpcListCopilotModelsResponse, type RpcListGrokReasoningEffortOptionsResponse, type RpcListOpencodeReasoningEffortOptionsResponse, type RpcCursorModel, @@ -64,6 +65,7 @@ export type { RpcListCursorModelsResponse, RpcListOpencodeModelsResponse, RpcListGrokModelsResponse, + RpcListCopilotModelsResponse, RpcListGrokReasoningEffortOptionsResponse, RpcListOpencodeReasoningEffortOptionsResponse, RpcCursorModel, @@ -1235,6 +1237,7 @@ async uploadScratchlistAttachment( source.serviceTier ?? undefined, childId, source.collaborationMode, + undefined, rpcResult.forkSession === true ) if (spawn.type !== 'success') { @@ -1591,6 +1594,7 @@ async uploadScratchlistAttachment( effort?: string | null serviceTier?: string | null collaborationMode?: CodexCollaborationMode + copilotAgentMode?: CopilotAgentMode } ): Promise { const session = this.sessionCache.getSession(sessionId) @@ -1615,6 +1619,7 @@ async uploadScratchlistAttachment( effort?: Session['effort'] serviceTier?: Session['serviceTier'] collaborationMode?: Session['collaborationMode'] + copilotAgentMode?: Session['copilotAgentMode'] } } if (typeof obj.error === 'string' && obj.error.trim().length > 0) { @@ -1649,7 +1654,8 @@ async uploadScratchlistAttachment( permissionMode?: PermissionMode, serviceTier?: string, existingSessionId?: string, - collaborationMode?: CodexCollaborationMode + collaborationMode?: CodexCollaborationMode, + copilotAgentMode?: CopilotAgentMode ): Promise<{ type: 'success'; sessionId: string } | { type: 'error'; message: string }> { return await this.rpcGateway.spawnSession( machineId, @@ -1665,7 +1671,8 @@ async uploadScratchlistAttachment( permissionMode, serviceTier, existingSessionId, - collaborationMode + collaborationMode, + copilotAgentMode ) } @@ -2104,6 +2111,7 @@ async uploadScratchlistAttachment( if (flavor === 'grok') return metadata.grokSessionId ?? null if (flavor === 'cursor') return metadata.cursorSessionId ?? null if (flavor === 'kimi') return metadata.kimiSessionId ?? null + if (flavor === 'copilot') return metadata.copilotSessionId ?? null if (flavor === 'pi') return metadata.piSessionId ?? null return metadata.claudeSessionId ?? this.recoverClaudeSessionIdFromMessages(session.id, namespace) @@ -2150,7 +2158,8 @@ async uploadScratchlistAttachment( effort: session.effort ?? null, modelReasoningEffort: session.modelReasoningEffort ?? null, permissionMode: session.permissionMode, - collaborationMode: session.collaborationMode + collaborationMode: session.collaborationMode, + copilotAgentMode: session.copilotAgentMode } } } @@ -2176,6 +2185,7 @@ async uploadScratchlistAttachment( modelReasoningEffort: target.modelReasoningEffort, permissionMode: target.permissionMode, collaborationMode: target.collaborationMode, + copilotAgentMode: target.copilotAgentMode, updatedAt: session?.updatedAt ?? 0, name: session?.metadata?.name, summary: session?.metadata?.summary?.text, @@ -2619,7 +2629,8 @@ async uploadScratchlistAttachment( preferredPermissionMode, session.serviceTier ?? undefined, access.sessionId, - session.collaborationMode ?? undefined + session.collaborationMode ?? undefined, + session.copilotAgentMode ?? undefined ) if (spawnResult.type !== 'success') { @@ -3063,6 +3074,7 @@ async uploadScratchlistAttachment( && (prev?.cursorSessionId ?? null) === (next.cursorSessionId ?? null) && (prev?.piSessionId ?? null) === (next.piSessionId ?? null) && (prev?.kimiSessionId ?? null) === (next.kimiSessionId ?? null) + && (prev?.copilotSessionId ?? null) === (next.copilotSessionId ?? null) } private canRunCursorDedup(session: Session): boolean { @@ -3403,6 +3415,14 @@ async uploadScratchlistAttachment( return await this.rpcGateway.listGrokReasoningEffortOptionsForSession(sessionId) } + async listCopilotModelsForCwd(machineId: string, cwd: string): Promise { + return await this.rpcGateway.listCopilotModelsForCwd(machineId, cwd) + } + + async listCopilotModelsForSession(sessionId: string): Promise { + return await this.rpcGateway.listCopilotModelsForSession(sessionId) + } + /** Generic Pi RPC — delegates to rpcGateway.callPiRpc. */ async callPiRpc(sessionId: string, method: string, params?: Record, timeoutMs?: number): Promise { return await this.rpcGateway.callPiRpc(sessionId, method, params, timeoutMs) diff --git a/hub/src/tunnel/tlsGate.ts b/hub/src/tunnel/tlsGate.ts index 8dcb46fe..6d1eccbc 100644 --- a/hub/src/tunnel/tlsGate.ts +++ b/hub/src/tunnel/tlsGate.ts @@ -64,10 +64,11 @@ function hostMatchesCertificate(host: string, cert: PeerCertificate): boolean { } if (hostIsIp) { - return commonName === host + return Array.isArray(commonName) ? commonName.includes(host) : commonName === host } - return dnsNameMatchesHost(host, commonName) + return (Array.isArray(commonName) ? commonName : [commonName]) + .some(name => dnsNameMatchesHost(host, name)) } function parseCertDate(value: string | undefined): Date | null { diff --git a/hub/src/web/routes/guards.ts b/hub/src/web/routes/guards.ts index 9056111d..0c2b2351 100644 --- a/hub/src/web/routes/guards.ts +++ b/hub/src/web/routes/guards.ts @@ -42,7 +42,7 @@ export function requireSessionFromParam( options?: { paramName?: string; requireActive?: boolean } ): { sessionId: string; session: Session } | Response { const paramName = options?.paramName ?? 'id' - const sessionId = c.req.param(paramName) + const sessionId = c.req.param(paramName) ?? '' const result = requireSession(c, engine, sessionId, { requireActive: options?.requireActive }) if (result instanceof Response) { return result diff --git a/hub/src/web/routes/machines.ts b/hub/src/web/routes/machines.ts index 4ca22090..451fc6b4 100644 --- a/hub/src/web/routes/machines.ts +++ b/hub/src/web/routes/machines.ts @@ -94,7 +94,8 @@ export function createMachinesRoutes(getSyncEngine: () => SyncEngine | null): Ho parsed.data.permissionMode, parsed.data.serviceTier, undefined, - parsed.data.collaborationMode + parsed.data.collaborationMode, + parsed.data.copilotAgentMode ) return c.json(result) }) @@ -232,6 +233,31 @@ export function createMachinesRoutes(getSyncEngine: () => SyncEngine | null): Ho } }) + app.get('/machines/:id/copilot-models', async (c) => { + const engine = getSyncEngine() + if (!engine) { + return c.json({ success: false, error: 'Not connected' }, 503) + } + + const machineId = c.req.param('id') + const machine = requireMachine(c, engine, machineId) + if (machine instanceof Response) return machine + + const cwd = (c.req.query('cwd') ?? '').trim() + if (!cwd) { + return c.json({ success: false, error: 'cwd query parameter is required' }, 400) + } + + try { + return c.json(await engine.listCopilotModelsForCwd(machineId, cwd)) + } catch (error) { + return c.json({ + success: false, + error: error instanceof Error ? error.message : 'Failed to list Copilot models' + }, 500) + } + }) + app.get('/machines/:id/cursor-models', async (c) => { const engine = getSyncEngine() if (!engine) { diff --git a/hub/src/web/routes/sessions.ts b/hub/src/web/routes/sessions.ts index b1bd315a..11b24761 100644 --- a/hub/src/web/routes/sessions.ts +++ b/hub/src/web/routes/sessions.ts @@ -11,6 +11,7 @@ import { ScratchlistEntryCreateRequestSchema, ScratchlistEntryUpdateRequestSchema, SessionCollaborationModeRequestSchema, + SessionCopilotAgentModeRequestSchema, SessionEffortRequestSchema, SessionModelReasoningEffortRequestSchema, SessionServiceTierRequestSchema, @@ -563,6 +564,40 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho } }) + app.post('/sessions/:id/copilot-agent-mode', async (c) => { + const engine = requireSyncEngine(c, getSyncEngine) + if (engine instanceof Response) { + return engine + } + + const sessionResult = requireSessionFromParam(c, engine, { requireActive: true }) + if (sessionResult instanceof Response) { + return sessionResult + } + + const flavor = sessionResult.session.metadata?.flavor ?? 'claude' + if (flavor !== 'copilot') { + return c.json({ error: 'Copilot agent mode is only supported for Copilot sessions' }, 400) + } + if (sessionResult.session.agentState?.controlledByUser === true) { + return c.json({ error: 'Copilot agent mode can only be changed for remote Copilot sessions' }, 409) + } + + const body = await c.req.json().catch(() => null) + const parsed = SessionCopilotAgentModeRequestSchema.safeParse(body) + if (!parsed.success) { + return c.json({ error: 'Invalid body' }, 400) + } + + try { + await engine.applySessionConfig(sessionResult.sessionId, { copilotAgentMode: parsed.data.mode }) + return c.json({ ok: true }) + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to apply Copilot agent mode' + return c.json({ error: message }, 409) + } + }) + app.post('/sessions/:id/model', async (c) => { const engine = requireSyncEngine(c, getSyncEngine) if (engine instanceof Response) { @@ -1276,6 +1311,24 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho } }) + app.get('/sessions/:id/copilot-models', async (c) => { + const engine = requireSyncEngine(c, getSyncEngine) + if (engine instanceof Response) return engine + const sessionResult = requireSessionFromParam(c, engine, { requireActive: true }) + if (sessionResult instanceof Response) return sessionResult + if (sessionResult.session.metadata?.flavor !== 'copilot') { + return c.json({ success: false, error: 'Copilot models are only available for Copilot sessions' }, 400) + } + try { + return c.json(await engine.listCopilotModelsForSession(sessionResult.sessionId)) + } catch (error) { + return c.json({ + success: false, + error: error instanceof Error ? error.message : 'Failed to list Copilot models' + }, 500) + } + }) + app.get('/sessions/:id/cursor-models', async (c) => { const engine = requireSyncEngine(c, getSyncEngine) if (engine instanceof Response) { diff --git a/shared/src/apiTypes.ts b/shared/src/apiTypes.ts index e9dfe601..59cf1cd4 100644 --- a/shared/src/apiTypes.ts +++ b/shared/src/apiTypes.ts @@ -2,6 +2,7 @@ import { z } from 'zod' import { AttachmentMetadataSchema, CodexCollaborationModeSchema, + CopilotAgentModeSchema, DecryptedMessageSchema, MachineSchema, PermissionModeSchema, @@ -202,6 +203,12 @@ export const SessionCollaborationModeRequestSchema = z.object({ export type SessionCollaborationModeRequest = z.infer +export const SessionCopilotAgentModeRequestSchema = z.object({ + mode: CopilotAgentModeSchema +}) + +export type SessionCopilotAgentModeRequest = z.infer + export const SessionModelRequestSchema = z.object({ model: z.union([ z.string().trim().min(1), @@ -502,7 +509,8 @@ export const SpawnSessionRequestSchema = z.object({ sessionType: z.enum(['simple', 'worktree']).optional(), worktreeName: z.string().optional(), serviceTier: z.enum(['fast', 'standard']).optional(), - collaborationMode: CodexCollaborationModeSchema.optional() + collaborationMode: CodexCollaborationModeSchema.optional(), + copilotAgentMode: CopilotAgentModeSchema.optional() }) export type SpawnSessionRequest = z.infer @@ -661,6 +669,20 @@ export type GrokModelsResponse = { } export type ListGrokModelsResponse = GrokModelsResponse +export type CopilotModelSummary = { + modelId: string + name?: string +} + +export type CopilotModelsResponse = { + success: boolean + availableModels?: CopilotModelSummary[] + currentModelId?: string | null + error?: string +} + +export type ListCopilotModelsResponse = CopilotModelsResponse + export type GrokReasoningEffortResponse = { success: boolean options?: GrokReasoningEffortOption[] diff --git a/shared/src/copilotModes.ts b/shared/src/copilotModes.ts new file mode 100644 index 00000000..685602bc --- /dev/null +++ b/shared/src/copilotModes.ts @@ -0,0 +1,45 @@ +export const COPILOT_AGENT_MODES = [ + 'interactive', + 'plan', + 'autopilot', +] as const + +export type CopilotAgentMode = typeof COPILOT_AGENT_MODES[number] + +export const COPILOT_AGENT_MODE_LABELS: Record = { + interactive: 'Interactive', + plan: 'Plan', + autopilot: 'Autopilot', +} + +export type CopilotAgentModeOption = { + mode: CopilotAgentMode + label: string +} + +export function getCopilotAgentModeLabel(mode: CopilotAgentMode): string { + return COPILOT_AGENT_MODE_LABELS[mode] +} + +export function getCopilotAgentModeOptions(): CopilotAgentModeOption[] { + return COPILOT_AGENT_MODES.map((mode) => ({ + mode, + label: getCopilotAgentModeLabel(mode) + })) +} + +export function isCopilotAgentMode(value: unknown): value is CopilotAgentMode { + return typeof value === 'string' + && (COPILOT_AGENT_MODES as readonly string[]).includes(value) +} + +/** + * Coerce legacy / invalid values. `fleet` was briefly treated as an agent mode; + * it is a slash command (`/fleet`) orthogonal to interactive/plan/autopilot. + */ +export function normalizeCopilotAgentMode(value: unknown): CopilotAgentMode { + if (value === 'fleet') { + return 'interactive' + } + return isCopilotAgentMode(value) ? value : 'interactive' +} diff --git a/shared/src/flavors.test.ts b/shared/src/flavors.test.ts index 579fc959..55f4f86d 100644 --- a/shared/src/flavors.test.ts +++ b/shared/src/flavors.test.ts @@ -48,6 +48,11 @@ describe('hasCapability', () => { expect(hasCapability('kimi', Capabilities.Effort)).toBe(false) }) + test('copilot supports model-change but not effort', () => { + expect(hasCapability('copilot', Capabilities.ModelChange)).toBe(true) + expect(hasCapability('copilot', Capabilities.Effort)).toBe(false) + }) + test('grok supports runtime model and effort switching through ACP', () => { expect(hasCapability('grok', Capabilities.ModelChange)).toBe(true) expect(hasCapability('grok', Capabilities.Effort)).toBe(true) @@ -72,6 +77,7 @@ describe('getFlavorLabel', () => { expect(getFlavorLabel('opencode')).toBe('OpenCode') expect(getFlavorLabel('pi')).toBe('Pi') expect(getFlavorLabel('kimi')).toBe('Kimi') + expect(getFlavorLabel('copilot')).toBe('Copilot') expect(getFlavorLabel('grok')).toBe('Grok Build') }) @@ -94,6 +100,7 @@ describe('isKnownFlavor', () => { expect(isKnownFlavor('opencode')).toBe(true) expect(isKnownFlavor('pi')).toBe(true) expect(isKnownFlavor('kimi')).toBe(true) + expect(isKnownFlavor('copilot')).toBe(true) expect(isKnownFlavor('grok')).toBe(true) }) diff --git a/shared/src/flavors.ts b/shared/src/flavors.ts index c6ace530..3f6b5de0 100644 --- a/shared/src/flavors.ts +++ b/shared/src/flavors.ts @@ -13,6 +13,7 @@ const FLAVOR_CAPS: Record> = { claude: new Set([Capabilities.ModelChange, Capabilities.Effort]), gemini: new Set([Capabilities.ModelChange]), kimi: new Set([Capabilities.ModelChange]), + copilot: new Set([Capabilities.ModelChange]), grok: new Set([Capabilities.ModelChange, Capabilities.Effort]), codex: new Set([Capabilities.ModelChange]), cursor: new Set([Capabilities.ModelChange]), @@ -25,6 +26,7 @@ const FLAVOR_LABELS: Record = { claude: 'Claude', gemini: 'Gemini', kimi: 'Kimi', + copilot: 'Copilot', grok: 'Grok Build', codex: 'Codex', cursor: 'Cursor', @@ -61,5 +63,6 @@ export function isCodexFamilyFlavor(flavor: string | null | undefined): boolean || flavor === 'gemini' || flavor === 'grok' || flavor === 'kimi' + || flavor === 'copilot' || flavor === 'opencode' } diff --git a/shared/src/index.ts b/shared/src/index.ts index 02853a1e..472dd111 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -14,6 +14,7 @@ export * from './socket' export * from './sessionSummary' export * from './sessionExport' export * from './piThinkingLevel' +export * from './copilotModes' export * from './slashCommands' export * from './utils' export * from './version' diff --git a/shared/src/modes.ts b/shared/src/modes.ts index 8c5e4507..e3d1041e 100644 --- a/shared/src/modes.ts +++ b/shared/src/modes.ts @@ -7,7 +7,7 @@ import { z } from 'zod' */ export const AGENT_MESSAGE_PAYLOAD_TYPE = 'codex' as const -export const AGENT_FLAVORS = ['claude', 'codex', 'cursor', 'gemini', 'grok', 'kimi', 'opencode', 'pi'] as const +export const AGENT_FLAVORS = ['claude', 'codex', 'copilot', 'cursor', 'gemini', 'grok', 'kimi', 'opencode', 'pi'] as const export type AgentFlavor = typeof AGENT_FLAVORS[number] export const AgentFlavorSchema = z.enum(AGENT_FLAVORS) @@ -34,6 +34,9 @@ export type GeminiPermissionMode = typeof GEMINI_PERMISSION_MODES[number] export const KIMI_PERMISSION_MODES = ['default', 'read-only', 'safe-yolo', 'yolo'] as const export type KimiPermissionMode = typeof KIMI_PERMISSION_MODES[number] +export const COPILOT_PERMISSION_MODES = ['default', 'read-only', 'safe-yolo', 'yolo'] as const +export type CopilotPermissionMode = typeof COPILOT_PERMISSION_MODES[number] + export const GROK_PERMISSION_MODES = ['default', 'auto', 'plan', 'bypassPermissions'] as const export type GrokPermissionMode = typeof GROK_PERMISSION_MODES[number] @@ -127,6 +130,9 @@ export function getPermissionModesForFlavor(flavor?: string | null): readonly Pe if (flavor === 'kimi') { return KIMI_PERMISSION_MODES } + if (flavor === 'copilot') { + return COPILOT_PERMISSION_MODES + } if (flavor === 'grok') { return GROK_PERMISSION_MODES } diff --git a/shared/src/resume.ts b/shared/src/resume.ts index 84af8af5..493ce56f 100644 --- a/shared/src/resume.ts +++ b/shared/src/resume.ts @@ -1,5 +1,5 @@ import { z } from 'zod' -import { CodexCollaborationModeSchema, PermissionModeSchema } from './schemas' +import { CodexCollaborationModeSchema, CopilotAgentModeSchema, PermissionModeSchema } from './schemas' import { AgentFlavorSchema } from './modes' export const LocalResumeTargetSchema = z.object({ @@ -17,7 +17,8 @@ export const LocalResumeTargetSchema = z.object({ modelReasoningEffort: z.string().nullable().optional(), serviceTier: z.string().nullable().optional(), permissionMode: PermissionModeSchema.optional(), - collaborationMode: CodexCollaborationModeSchema.optional() + collaborationMode: CodexCollaborationModeSchema.optional(), + copilotAgentMode: CopilotAgentModeSchema.optional() }) export type LocalResumeTarget = z.infer diff --git a/shared/src/rpcMethods.ts b/shared/src/rpcMethods.ts index 0ffd2da3..ca984fd2 100644 --- a/shared/src/rpcMethods.ts +++ b/shared/src/rpcMethods.ts @@ -37,6 +37,8 @@ export const RPC_METHODS = { ListGrokModelsForCwd: 'listGrokModelsForCwd', ListGrokModels: 'listGrokModels', ListGrokReasoningEffortOptions: 'listGrokReasoningEffortOptions', + ListCopilotModelsForCwd: 'listCopilotModelsForCwd', + ListCopilotModels: 'listCopilotModels', ListOpencodeReasoningEffortOptions: 'listOpencodeReasoningEffortOptions', ForkConversation: 'fork-conversation', RewindConversation: 'rewind-conversation', diff --git a/shared/src/schemas.ts b/shared/src/schemas.ts index 80c6e87f..b392afba 100644 --- a/shared/src/schemas.ts +++ b/shared/src/schemas.ts @@ -1,8 +1,14 @@ import { z } from 'zod' +import { COPILOT_AGENT_MODES, type CopilotAgentMode } from './copilotModes' import { CODEX_COLLABORATION_MODES, PERMISSION_MODES } from './modes' export const PermissionModeSchema = z.enum(PERMISSION_MODES) export const CodexCollaborationModeSchema = z.enum(CODEX_COLLABORATION_MODES) +/** Accept legacy `fleet` (was briefly a peer mode) and coerce to interactive. */ +export const CopilotAgentModeSchema = z.union([ + z.enum(COPILOT_AGENT_MODES), + z.literal('fleet').transform((): CopilotAgentMode => 'interactive'), +]) export const SessionEndReasonSchema = z.enum(['completed', 'terminated', 'error', 'handoff', 'cleared']) export type SessionEndReason = z.infer @@ -74,6 +80,7 @@ export const MetadataSchema = z.object({ // tiann/hapi#873. cursorMigrationState: z.enum(['in_progress', 'ambiguous']).optional(), kimiSessionId: z.string().optional(), + copilotSessionId: z.string().optional(), piSessionId: z.string().optional(), piResumeAttempt: z.object({ state: z.enum(['resuming', 'terminating', 'quarantined']), @@ -107,6 +114,7 @@ export const MetadataSchema = z.object({ // Durable in-progress state for runner-backed OpenCode /clear. opencodeClearOperation: OpencodeClearOperationSchema.optional(), preferredPermissionMode: PermissionModeSchema.optional(), + preferredCopilotAgentMode: CopilotAgentModeSchema.optional(), flavor: z.string().nullish(), capabilities: SessionCapabilitiesSchema.optional(), conversationHistoryPoints: z.record(z.string(), z.literal(true)).optional(), @@ -279,7 +287,8 @@ export const SessionSchema = z.object({ effort: z.string().nullable().optional().default(null), serviceTier: z.string().nullable().optional().default(null), permissionMode: PermissionModeSchema.optional(), - collaborationMode: CodexCollaborationModeSchema.optional() + collaborationMode: CodexCollaborationModeSchema.optional(), + copilotAgentMode: CopilotAgentModeSchema.optional() }) export type Session = z.infer @@ -295,6 +304,7 @@ export const SessionPatchSchema = z.object({ serviceTier: z.string().nullable().optional(), permissionMode: PermissionModeSchema.optional(), collaborationMode: CodexCollaborationModeSchema.optional(), + copilotAgentMode: CopilotAgentModeSchema.optional(), backgroundTaskCount: z.number().optional(), // tiann/hapi#893 (scratchlist v2). Bumped whenever any entry on the // session_scratchlist table mutates. Web client uses the change as a diff --git a/shared/src/sessionSummary.ts b/shared/src/sessionSummary.ts index ba010cb7..1ca3c7d6 100644 --- a/shared/src/sessionSummary.ts +++ b/shared/src/sessionSummary.ts @@ -116,6 +116,7 @@ const AGENT_SESSION_ID_FIELD_BY_FLAVOR = { grok: 'grokSessionId', cursor: 'cursorSessionId', kimi: 'kimiSessionId', + copilot: 'copilotSessionId', pi: 'piSessionId' } as const satisfies Record @@ -137,6 +138,7 @@ function getSummaryAgentSessionId(metadata: Metadata): string | undefined { ?? metadata.grokSessionId ?? metadata.cursorSessionId ?? metadata.kimiSessionId + ?? metadata.copilotSessionId ?? undefined } diff --git a/shared/src/slashCommands.ts b/shared/src/slashCommands.ts index a8aebf9f..0c1a9aa5 100644 --- a/shared/src/slashCommands.ts +++ b/shared/src/slashCommands.ts @@ -52,6 +52,28 @@ export const BUILTIN_SLASH_COMMANDS = { cursor: [ { name: 'compress', description: 'Compress conversation context to free window space (pass-through to Cursor agent)', source: 'builtin' }, ], + copilot: [ + { name: 'help', description: 'Show supported Copilot slash commands', source: 'builtin' }, + { name: 'status', description: 'Show current Copilot session config', source: 'builtin' }, + { name: 'plan', description: 'Start plan mode for structured implementation planning', source: 'builtin' }, + { name: 'autopilot', description: 'Start autopilot mode for autonomous multi-step work', source: 'builtin' }, + { name: 'fleet', description: 'Run parallel subagents for a task (combine with Interactive/Plan/Autopilot)', source: 'builtin' }, + { name: 'tasks', description: 'List or manage fleet tasks', source: 'builtin' }, + { name: 'subagents', description: 'Manage Copilot subagents', source: 'builtin' }, + { name: 'agents', description: 'Alias for /subagents', source: 'builtin' }, + { name: 'delegate', description: 'Delegate work to a subagent', source: 'builtin' }, + { name: 'agent', description: 'Select or configure a custom agent', source: 'builtin' }, + { name: 'rubber-duck', description: 'Consult the rubber-duck agent for a second opinion on plans, code, and tests', source: 'builtin' }, + { name: 'security-review', description: 'Run a focused security review of active local code changes', source: 'builtin' }, + { name: 'research', description: 'Run a deep research investigation across the codebase and web', source: 'builtin' }, + { name: 'review', description: 'Run the code-review agent on current changes', source: 'builtin' }, + { name: 'skills', description: 'List, inspect, add, or remove Copilot skills', source: 'builtin' }, + { name: 'context', description: 'Show current context usage', source: 'builtin' }, + { name: 'model', description: 'Show or switch the active model', source: 'builtin' }, + { name: 'permissions', description: 'Show or set permission mode', source: 'builtin' }, + { name: 'permission', description: 'Alias for /permissions', source: 'builtin' }, + { name: 'usage', description: 'Show session usage metrics', source: 'builtin' }, + ], kimi: [], pi: [], } as const satisfies Record diff --git a/shared/src/socket.ts b/shared/src/socket.ts index 977dcd5c..a8281649 100644 --- a/shared/src/socket.ts +++ b/shared/src/socket.ts @@ -1,5 +1,6 @@ import { z } from 'zod' import type { CodexCollaborationMode, PermissionMode } from './modes' +import type { CopilotAgentMode } from './copilotModes' import type { SessionEndReason } from './schemas' export { SessionEndReasonSchema, type SessionEndReason } from './schemas' @@ -212,6 +213,7 @@ export interface ClientToServerEvents { effort?: string | null serviceTier?: string | null collaborationMode?: CodexCollaborationMode + copilotAgentMode?: CopilotAgentMode }) => void /** CLI agent finished session/load (or equivalent) and can accept prompts. */ 'session-ready': (data: { sid: string; time: number }) => void diff --git a/shared/src/types.ts b/shared/src/types.ts index ffe12c6d..955ca42c 100644 --- a/shared/src/types.ts +++ b/shared/src/types.ts @@ -39,6 +39,7 @@ export type { GeminiPermissionMode, GrokPermissionMode, KimiPermissionMode, + CopilotPermissionMode, OpencodePermissionMode, PermissionMode, PermissionModeOption, @@ -46,3 +47,4 @@ export type { } from './modes' export type { ClaudeModelPreset, GeminiModelPreset } from './models' +export type { CopilotAgentMode } from './copilotModes' diff --git a/web/package.json b/web/package.json index 4bffcd68..82408973 100644 --- a/web/package.json +++ b/web/package.json @@ -41,6 +41,7 @@ "mermaid": "^11.12.0", "react": "^19.2.3", "react-dom": "^19.2.3", + "react-markdown": "^10.1.0", "qrcode": "^1.5.4", "rehype-katex": "^7.0.1", "remark-breaks": "^4.0.0", @@ -61,6 +62,8 @@ "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", "@types/qrcode": "^1.5.6", + "@types/hast": "^3.0.4", + "@types/mdast": "^4.0.4", "@tailwindcss/postcss": "^4.1.18", "@vitejs/plugin-react": "^5.1.2", "autoprefixer": "^10.4.23", @@ -69,11 +72,13 @@ "postcss": "^8.5.6", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", + "remark-stringify": "^11.0.0", "tailwindcss": "^4.1.18", "typescript": "^5.9.3", "unified": "^11.0.5", "vite": "^7.3.0", "vitest": "^4.0.16", + "vfile": "^6.0.3", "@playwright/test": "1.60.0" } } diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 9b4e692e..330928c9 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -9,6 +9,7 @@ import type { CodexDesktopStatusResponse, CodexArchiveSessionResponse, CodexCollaborationMode, + CopilotAgentMode, FileSearchResponse, MachinesResponse, MessagesResponse, @@ -34,6 +35,7 @@ import type { FileReadResponse, GitCommandResponse, GrokModelsResponse, + CopilotModelsResponse, GrokReasoningEffortResponse, ListDirectoryResponse, MachineListDirectoryResponse, @@ -698,7 +700,8 @@ export class ApiClient { effort?: string, permissionMode?: PermissionMode, serviceTier?: 'fast' | 'standard', - collaborationMode?: 'default' | 'plan' + collaborationMode?: CodexCollaborationMode, + copilotAgentMode?: CopilotAgentMode ): Promise { return await this.request(`/api/machines/${encodeURIComponent(machineId)}/spawn`, { method: 'POST', @@ -713,7 +716,8 @@ export class ApiClient { effort, permissionMode, serviceTier, - collaborationMode + collaborationMode, + copilotAgentMode }) }) } @@ -768,12 +772,31 @@ export class ApiClient { ) } + async getMachineCopilotModelsForCwd(machineId: string, cwd: string): Promise { + return await this.request( + `/api/machines/${encodeURIComponent(machineId)}/copilot-models?cwd=${encodeURIComponent(cwd)}` + ) + } + async getSessionGrokModels(sessionId: string): Promise { return await this.request( `/api/sessions/${encodeURIComponent(sessionId)}/grok-models` ) } + async getSessionCopilotModels(sessionId: string): Promise { + return await this.request( + `/api/sessions/${encodeURIComponent(sessionId)}/copilot-models` + ) + } + + async setCopilotAgentMode(sessionId: string, mode: CopilotAgentMode): Promise { + await this.request(`/api/sessions/${encodeURIComponent(sessionId)}/copilot-agent-mode`, { + method: 'POST', + body: JSON.stringify({ mode }) + }) + } + async getSessionGrokReasoningEffortOptions(sessionId: string): Promise { return await this.request( `/api/sessions/${encodeURIComponent(sessionId)}/grok-reasoning-effort-options` diff --git a/web/src/components/AgentFlavorIcon.test.tsx b/web/src/components/AgentFlavorIcon.test.tsx index 76d9f706..97e3d160 100644 --- a/web/src/components/AgentFlavorIcon.test.tsx +++ b/web/src/components/AgentFlavorIcon.test.tsx @@ -39,6 +39,13 @@ describe('AgentFlavorIcon', () => { expect(svg?.getAttribute('viewBox')).toBe('0 0 800 800') }) + it('renders the GitHub mark SVG for the copilot flavor', () => { + const { container } = render() + const svg = container.querySelector('svg') + expect(svg).not.toBeNull() + expect(getWrapper(container).className).not.toContain('rounded-sm') + }) + it.each([null, undefined, '', ' ', 'mystery-cli'])( 'renders the "Un" fallback badge for flavor %j', (flavor) => { diff --git a/web/src/components/AgentFlavorIcon.tsx b/web/src/components/AgentFlavorIcon.tsx index 8df6f416..3325b353 100644 --- a/web/src/components/AgentFlavorIcon.tsx +++ b/web/src/components/AgentFlavorIcon.tsx @@ -9,6 +9,7 @@ import GrokMono from '@lobehub/icons/es/Grok/components/Mono' import KimiMono from '@lobehub/icons/es/Kimi/components/Mono' import OpenCodeMono from '@lobehub/icons/es/OpenCode/components/Mono' import type { IconType } from '@lobehub/icons/es/types' +import { CopilotIcon } from '@/components/icons/CopilotIcon' // Brand logos per agent flavor. Color variant where it stays visible on both // light and dark surfaces (claude/codex/gemini); Mono (currentColor) where the @@ -41,6 +42,17 @@ const UNKNOWN_FLAVOR_BADGE = { export function AgentFlavorIcon({ flavor, className }: { flavor?: string | null; className?: string }) { const normalized = (flavor ?? '').trim().toLowerCase() const sizeClass = className ?? 'h-4 w-4' + if (normalized === 'copilot') { + return ( + + ) + } + const Logo = FLAVOR_LOGOS[normalized] if (Logo) { diff --git a/web/src/components/AssistantChat/HappyComposer.tsx b/web/src/components/AssistantChat/HappyComposer.tsx index e6ac8dbe..d57bf0a6 100644 --- a/web/src/components/AssistantChat/HappyComposer.tsx +++ b/web/src/components/AssistantChat/HappyComposer.tsx @@ -1,4 +1,9 @@ -import { getCodexCollaborationModeOptions, getPermissionModeOptionsForFlavor } from '@hapi/protocol' +import { + getCodexCollaborationModeOptions, + getCopilotAgentModeOptions, + getPermissionModeOptionsForFlavor, + type CopilotAgentMode +} from '@hapi/protocol' import { ComposerPrimitive, useAui, useAuiState } from '@assistant-ui/react' import { flushTapSync } from '@assistant-ui/tap' import { @@ -213,6 +218,7 @@ export function HappyComposer(props: { disabled?: boolean permissionMode?: PermissionMode collaborationMode?: CodexCollaborationMode + copilotAgentMode?: CopilotAgentMode model?: string | null modelReasoningEffort?: string | null effort?: string | null @@ -243,6 +249,7 @@ export function HappyComposer(props: { /** Cursor: effort/variant wire ids for the selected base model. */ modelEffortOptions?: Array<{ value: string; label: string }> onCollaborationModeChange?: (mode: CodexCollaborationMode) => void + onCopilotAgentModeChange?: (mode: CopilotAgentMode) => void onPermissionModeChange?: (mode: PermissionMode) => void onModelChange?: (model: { provider: string; modelId: string } | string | null) => void /** Cursor: effort/variant wire id (separate from base model change). */ @@ -299,6 +306,7 @@ export function HappyComposer(props: { disabled = false, permissionMode: rawPermissionMode, collaborationMode: rawCollaborationMode, + copilotAgentMode: rawCopilotAgentMode, model: rawModel, modelReasoningEffort: rawModelReasoningEffort, effort: rawEffort, @@ -322,6 +330,7 @@ export function HappyComposer(props: { selectedModelVariant, modelEffortOptions, onCollaborationModeChange, + onCopilotAgentModeChange, onPermissionModeChange, onModelChange, onModelEffortChange, @@ -350,6 +359,7 @@ export function HappyComposer(props: { // Use ?? so missing values fall back to default (destructuring defaults only handle undefined) const permissionMode = rawPermissionMode ?? 'default' const collaborationMode = rawCollaborationMode ?? 'default' + const copilotAgentMode = rawCopilotAgentMode ?? 'interactive' const model = rawModel ?? null const modelReasoningEffort = rawModelReasoningEffort ?? null const effort = rawEffort ?? null @@ -791,6 +801,10 @@ export function HappyComposer(props: { () => agentFlavor === 'codex' ? getCodexCollaborationModeOptions() : [], [agentFlavor] ) + const copilotAgentModeOptions = useMemo( + () => agentFlavor === 'copilot' ? getCopilotAgentModeOptions() : [], + [agentFlavor] + ) const modelOptions = useMemo( () => getModelOptionsForFlavor(agentFlavor, model, availableModelOptions), [agentFlavor, model, availableModelOptions] @@ -1148,6 +1162,13 @@ export function HappyComposer(props: { haptic('light') }, [onCollaborationModeChange, controlsDisabled, haptic]) + const handleCopilotAgentModeChange = useCallback((mode: CopilotAgentMode) => { + if (!onCopilotAgentModeChange || controlsDisabled) return + onCopilotAgentModeChange(mode) + setShowSettings(false) + haptic('light') + }, [onCopilotAgentModeChange, controlsDisabled, haptic]) + const handleModelChange = useCallback((nextModel: { provider: string; modelId: string } | string | null) => { if (!onModelChange || controlsDisabled) return onModelChange(nextModel) @@ -1192,6 +1213,7 @@ export function HappyComposer(props: { ], [t]) const showCollaborationSettings = Boolean(onCollaborationModeChange && collaborationModeOptions.length > 0) + const showCopilotAgentModeSettings = Boolean(onCopilotAgentModeChange && copilotAgentModeOptions.length > 0) const showPermissionSettings = Boolean(onPermissionModeChange && permissionModeOptions.length > 0) const showModelSettings = Boolean(onModelChange && supportsModelChange(agentFlavor) && (piModels && piModels.length > 0 || modelOptions.length > 0)) const showModelEffortSettings = Boolean( @@ -1206,6 +1228,7 @@ export function HappyComposer(props: { const showFastModeSettings = Boolean(onServiceTierChange) const showSettingsButton = Boolean( showCollaborationSettings + || showCopilotAgentModeSettings || showPermissionSettings || showModelSettings || showModelEffortSettings @@ -1303,7 +1326,7 @@ export function HappyComposer(props: { } // Non-Pi flavors: original unified gear menu - if (showSettings && (showCollaborationSettings || showPermissionSettings || showModelSettings || showModelEffortSettings || showModelReasoningEffortSettings || showEffortSettings || showFastModeSettings)) { + if (showSettings && (showCollaborationSettings || showCopilotAgentModeSettings || showPermissionSettings || showModelSettings || showModelEffortSettings || showModelReasoningEffortSettings || showEffortSettings || showFastModeSettings)) { return (
@@ -1344,7 +1367,44 @@ export function HappyComposer(props: {
) : null} - {showCollaborationSettings && (showPermissionSettings || showModelSettings || showModelReasoningEffortSettings || showEffortSettings) ? ( + {showCopilotAgentModeSettings ? ( +
+
+ {t('misc.copilotAgentMode')} +
+ {copilotAgentModeOptions.map((option) => ( + + ))} +
+ ) : null} + + {(showCollaborationSettings || showCopilotAgentModeSettings) && (showPermissionSettings || showModelSettings || showModelReasoningEffortSettings || showEffortSettings) ? (
) : null} @@ -1385,7 +1445,7 @@ export function HappyComposer(props: {
) : null} - {(showCollaborationSettings || showPermissionSettings) && (showModelSettings || showModelEffortSettings || showModelReasoningEffortSettings || showEffortSettings) ? ( + {(showCollaborationSettings || showCopilotAgentModeSettings || showPermissionSettings) && (showModelSettings || showModelEffortSettings || showModelReasoningEffortSettings || showEffortSettings) ? (
) : null} @@ -1639,6 +1699,7 @@ export function HappyComposer(props: { selectedPiModel, closeAllPanels, showCollaborationSettings, + showCopilotAgentModeSettings, showPermissionSettings, showModelSettings, showModelEffortSettings, @@ -1662,8 +1723,11 @@ export function HappyComposer(props: { effort, displayedServiceTier, collaborationModeOptions, + copilotAgentModeOptions, permissionModeOptions, handleCollaborationChange, + handleCopilotAgentModeChange, + copilotAgentMode, handlePermissionChange, handleModelChange, handleModelReasoningEffortChange, @@ -1709,6 +1773,7 @@ export function HappyComposer(props: { serviceTier={serviceTier} permissionMode={permissionMode} collaborationMode={collaborationMode} + copilotAgentMode={copilotAgentMode} agentFlavor={agentFlavor} voiceStatus={effectiveVoiceStatus} /> diff --git a/web/src/components/AssistantChat/StatusBar.tsx b/web/src/components/AssistantChat/StatusBar.tsx index 3076c31e..79c8d0a7 100644 --- a/web/src/components/AssistantChat/StatusBar.tsx +++ b/web/src/components/AssistantChat/StatusBar.tsx @@ -1,5 +1,6 @@ import { getCodexCollaborationModeLabel, + getCopilotAgentModeLabel, getPermissionModeLabel, getPermissionModeTone, isPermissionModeAllowedForFlavor @@ -209,6 +210,7 @@ export function StatusBar(props: { serviceTier?: string | null permissionMode?: PermissionMode collaborationMode?: CodexCollaborationMode + copilotAgentMode?: import('@hapi/protocol').CopilotAgentMode agentFlavor?: string | null voiceStatus?: ConversationStatus }) { @@ -247,9 +249,12 @@ export function StatusBar(props: { const contextUsedPercentage = contextUsageDetails?.usedPercentage ?? null const permissionMode = props.permissionMode + // Copilot always shows permission (including Default) so model=auto sessions + // still surface the bottom-right mode chip. Other flavors keep Codex-style + // "hide default" parity. const displayPermissionMode = permissionMode - && permissionMode !== 'default' && isPermissionModeAllowedForFlavor(permissionMode, props.agentFlavor) + && (permissionMode !== 'default' || props.agentFlavor === 'copilot') ? permissionMode : null @@ -262,6 +267,14 @@ export function StatusBar(props: { const collaborationModeLabel = displayCollaborationMode ? getCodexCollaborationModeLabel(displayCollaborationMode) : null + const displayCopilotAgentMode = props.agentFlavor === 'copilot' + && props.copilotAgentMode + && props.copilotAgentMode !== 'interactive' + ? props.copilotAgentMode + : null + const copilotAgentModeLabel = displayCopilotAgentMode + ? getCopilotAgentModeLabel(displayCopilotAgentMode) + : null const reasoningEffort = getReasoningEffortForFlavor( props.agentFlavor, props.modelReasoningEffort, @@ -366,6 +379,11 @@ export function StatusBar(props: { {collaborationModeLabel} ) : null} + {copilotAgentModeLabel ? ( + + {copilotAgentModeLabel} + + ) : null} {displayPermissionMode ? ( {permissionModeLabel} diff --git a/web/src/components/AssistantChat/modelOptions.test.ts b/web/src/components/AssistantChat/modelOptions.test.ts index 4b01e1e3..48db183b 100644 --- a/web/src/components/AssistantChat/modelOptions.test.ts +++ b/web/src/components/AssistantChat/modelOptions.test.ts @@ -164,6 +164,19 @@ describe('getModelOptionsForFlavor', () => { { value: 'grok-4.5', label: 'grok-4.5' } ]) }) + + it('uses null for Copilot Auto with dynamic model options', () => { + const options = getModelOptionsForFlavor('copilot', null, [ + { value: null, label: 'Auto' }, + { value: 'gpt-5.6', label: 'GPT-5.6' } + ]) + + expect(options).toEqual([ + { value: null, label: 'Auto' }, + { value: 'gpt-5.6', label: 'GPT-5.6' } + ]) + expect(options.find((option) => option.value === null)?.label).toBe('Auto') + }) }) describe('getNextModelForFlavor', () => { @@ -233,6 +246,13 @@ describe('getNextModelForFlavor', () => { expect(getNextModelForFlavor('grok', 'grok-4.5')).toBe('grok-4.5') }) + it('resets a Copilot model to null when cycling to Auto', () => { + expect(getNextModelForFlavor('copilot', 'gpt-5.6', [ + { value: null, label: 'Auto' }, + { value: 'gpt-5.6', label: 'GPT-5.6' } + ])).toBeNull() + }) + it('returns null for pi without a current model (no Claude fallback)', () => { const next = getNextModelForFlavor('pi', null) expect(next).toBeNull() diff --git a/web/src/components/AssistantChat/modelOptions.ts b/web/src/components/AssistantChat/modelOptions.ts index 0f017fec..5b2f00f1 100644 --- a/web/src/components/AssistantChat/modelOptions.ts +++ b/web/src/components/AssistantChat/modelOptions.ts @@ -127,6 +127,12 @@ export function getModelOptionsForFlavor( if (flavor === 'kimi') { return withCurrentModelOption([{ value: null, label: 'Default' }], currentModel) } + if (flavor === 'copilot') { + if (customOptions && customOptions.length > 0) { + return withCurrentModelOption(customOptions, currentModel) + } + return withCurrentModelOption([{ value: null, label: 'Auto' }], currentModel) + } if (flavor === 'grok') { return withCurrentModelOption([{ value: null, label: 'Default' }], currentModel) } @@ -179,6 +185,9 @@ export function getNextModelForFlavor( if (flavor === 'kimi') { return normalizeCurrentModel(currentModel) } + if (flavor === 'copilot') { + return normalizeCurrentModel(currentModel) + } if (flavor === 'grok') { return normalizeCurrentModel(currentModel) } diff --git a/web/src/components/NewSession/CodexFamilyPermissionModeSelector.test.tsx b/web/src/components/NewSession/CodexFamilyPermissionModeSelector.test.tsx new file mode 100644 index 00000000..7f9efebb --- /dev/null +++ b/web/src/components/NewSession/CodexFamilyPermissionModeSelector.test.tsx @@ -0,0 +1,47 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { I18nProvider } from '@/lib/i18n-context' +import { CodexFamilyPermissionModeSelector } from './CodexFamilyPermissionModeSelector' + +function renderSelector(props: Parameters[0]) { + return render( + + + + ) +} + +describe('CodexFamilyPermissionModeSelector', () => { + it('renders permission modes for copilot', () => { + renderSelector({ + agent: 'copilot', + value: 'default', + isDisabled: false, + onChange: vi.fn(), + }) + expect(screen.getByRole('combobox')).toBeTruthy() + expect(screen.getByRole('option', { name: 'Yolo' })).toBeTruthy() + }) + + it('hides for claude', () => { + const { container } = renderSelector({ + agent: 'claude', + value: 'default', + isDisabled: false, + onChange: vi.fn(), + }) + expect(container.firstChild).toBeNull() + }) + + it('calls onChange when a mode is selected', () => { + const onChange = vi.fn() + renderSelector({ + agent: 'copilot', + value: 'default', + isDisabled: false, + onChange, + }) + fireEvent.change(screen.getByRole('combobox'), { target: { value: 'yolo' } }) + expect(onChange).toHaveBeenCalledWith('yolo') + }) +}) diff --git a/web/src/components/NewSession/CodexFamilyPermissionModeSelector.tsx b/web/src/components/NewSession/CodexFamilyPermissionModeSelector.tsx new file mode 100644 index 00000000..37543e5d --- /dev/null +++ b/web/src/components/NewSession/CodexFamilyPermissionModeSelector.tsx @@ -0,0 +1,39 @@ +import { getPermissionModeOptionsForFlavor, type PermissionMode } from '@hapi/protocol' +import { useTranslation } from '@/lib/use-translation' +import { usesCodexFamilyPermissionModes } from '@/lib/codexFamilyPermissionAgents' +import type { AgentType } from './types' + +export function CodexFamilyPermissionModeSelector(props: { + agent: AgentType + value: PermissionMode + isDisabled: boolean + onChange: (value: PermissionMode) => void +}) { + const { t } = useTranslation() + + if (!usesCodexFamilyPermissionModes(props.agent)) { + return null + } + + const options = getPermissionModeOptionsForFlavor(props.agent) + + return ( +
+ + +
+ ) +} diff --git a/web/src/components/NewSession/CopilotAgentModeSelector.tsx b/web/src/components/NewSession/CopilotAgentModeSelector.tsx new file mode 100644 index 00000000..f0b47e2b --- /dev/null +++ b/web/src/components/NewSession/CopilotAgentModeSelector.tsx @@ -0,0 +1,36 @@ +import { getCopilotAgentModeOptions, type CopilotAgentMode } from '@hapi/protocol' +import { useTranslation } from '@/lib/use-translation' +import type { AgentType } from './types' + +export function CopilotAgentModeSelector(props: { + agent: AgentType + value: CopilotAgentMode + isDisabled: boolean + onChange: (value: CopilotAgentMode) => void +}) { + const { t } = useTranslation() + + if (props.agent !== 'copilot') { + return null + } + + return ( +
+ + +
+ ) +} diff --git a/web/src/components/NewSession/ModelSelector.tsx b/web/src/components/NewSession/ModelSelector.tsx index 587b9270..2895c6c9 100644 --- a/web/src/components/NewSession/ModelSelector.tsx +++ b/web/src/components/NewSession/ModelSelector.tsx @@ -22,10 +22,7 @@ export function ModelSelector(props: { return (
({ notification: vi.fn(), checkPathsExists: vi.fn(), codexModelsLoading: false, - directoryExists: undefined as boolean | undefined + directoryExists: undefined as boolean | undefined, + copilotModels: [] as Array<{ modelId: string; name?: string }>, + copilotModelsLoading: false })) vi.mock('@/lib/use-translation', () => ({ @@ -105,6 +107,14 @@ vi.mock('@/hooks/queries/useGrokModelsForCwd', () => ({ error: null }) })) +vi.mock('@/hooks/queries/useCopilotModelsForCwd', () => ({ + useCopilotModelsForCwd: () => ({ + availableModels: mocks.copilotModels, + currentModelId: null, + isLoading: mocks.copilotModelsLoading, + error: null + }) +})) vi.mock('../../utils/formatRunnerSpawnError', () => ({ formatRunnerSpawnError: () => null })) @@ -115,6 +125,8 @@ vi.mock('./DirectorySection', () => ({ DirectorySection: () => null })) vi.mock('./MachineSelector', () => ({ MachineSelector: () => null })) vi.mock('./SessionTypeSelector', () => ({ SessionTypeSelector: () => null })) vi.mock('./GrokPermissionModeSelector', () => ({ GrokPermissionModeSelector: () => null })) +vi.mock('./CodexFamilyPermissionModeSelector', () => ({ CodexFamilyPermissionModeSelector: () => null })) +vi.mock('./CopilotAgentModeSelector', () => ({ CopilotAgentModeSelector: () => null })) vi.mock('./YoloToggle', () => ({ YoloToggle: () => null })) vi.mock('./OpencodeModelSelector', () => ({ OpencodeModelSelector: () => null })) vi.mock('./LaunchEffortSelector', () => ({ @@ -123,10 +135,17 @@ vi.mock('./LaunchEffortSelector', () => ({ ) })) vi.mock('./ModelSelector', () => ({ - ModelSelector: (props: { model: string; onModelChange: (model: string) => void }) => ( - + ModelSelector: (props: { + model: string + options?: Array<{ value: string; label: string }> + onModelChange: (model: string) => void + }) => ( + <> + +
{props.options?.map((option) => option.label).join(',')}
+ ) })) vi.mock('./ReasoningEffortSelector', () => ({ @@ -160,6 +179,8 @@ describe('NewSession launch preferences', () => { mocks.checkPathsExists.mockImplementation(async () => ({ 'C:\\repo': mocks.directoryExists })) mocks.codexModelsLoading = false mocks.directoryExists = true + mocks.copilotModels = [] + mocks.copilotModelsLoading = false savePreferredAgent('codex') }) @@ -188,6 +209,54 @@ describe('NewSession launch preferences', () => { }) }) + it('shows discovered Copilot models for the selected directory', async () => { + mocks.copilotModels = [ + { modelId: 'gpt-5.6', name: 'GPT-5.6' }, + { modelId: 'auto', name: 'Auto' } + ] + + render( + {}} + /> + ) + + fireEvent.click(screen.getByLabelText('Copilot')) + + await waitFor(() => { + expect(screen.getByTestId('model-options')).toHaveTextContent('Auto,GPT-5.6') + }) + }) + + it('disables creation while a remembered Copilot model is being validated', async () => { + mocks.copilotModelsLoading = true + savePreferredAgent('copilot') + savePreferredLaunchSettings('machine-1', 'copilot', { + model: 'gpt-5.6', + cursorSelectedBase: 'auto', + effort: 'auto', + modelReasoningEffort: 'default' + }) + + render( + {}} + /> + ) + + await waitFor(() => expect(screen.getByTestId('create')).toBeDisabled()) + }) + it.each([ ['model', 'gpt-5.6-sol', 'default'], ['reasoning effort', 'auto', 'xhigh'] @@ -350,7 +419,9 @@ describe('NewSession launch preferences', () => { modelReasoningEffort: 'max', serviceTier: 'standard', collaborationMode: 'default', + copilotAgentMode: 'interactive', yoloMode: false, + codexFamilyPermissionMode: 'default', grokPermissionMode: 'default', sessionType: 'simple', worktreeName: '' diff --git a/web/src/components/NewSession/index.tsx b/web/src/components/NewSession/index.tsx index fb81c7f1..bbce2b13 100644 --- a/web/src/components/NewSession/index.tsx +++ b/web/src/components/NewSession/index.tsx @@ -1,7 +1,7 @@ import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react' import type { ApiClient } from '@/api/client' import type { CodexDuplicateSessionGroup, CodexLocalSessionSummary, Machine } from '@/types/api' -import type { CodexCollaborationMode, GrokPermissionMode } from '@hapi/protocol' +import type { CodexCollaborationMode, GrokPermissionMode, PermissionMode, CopilotAgentMode } from '@hapi/protocol' import { codexModelAdvertisesFastTier } from '@/components/AssistantChat/codexFastMode' import { usePlatform } from '@/hooks/usePlatform' import { useMachinePathsExists } from '@/hooks/useMachinePathsExists' @@ -10,6 +10,7 @@ import { useCodexModels } from '@/hooks/queries/useCodexModels' import { useCursorModelsForMachine } from '@/hooks/queries/useCursorModelsForMachine' import { useOpencodeModelsForCwd } from '@/hooks/queries/useOpencodeModelsForCwd' import { useGrokModelsForCwd } from '@/hooks/queries/useGrokModelsForCwd' +import { useCopilotModelsForCwd } from '@/hooks/queries/useCopilotModelsForCwd' import { useSessions } from '@/hooks/queries/useSessions' import { useActiveSuggestions, type Suggestion } from '@/hooks/useActiveSuggestions' import { useDirectorySuggestions } from '@/hooks/useDirectorySuggestions' @@ -42,6 +43,8 @@ import { CodexImportActions } from './CodexImportActions' import { clearBatchImportedCodexSelection, resolveCodexImportRedirectSessionId } from './codexImportMerge' import { DirectorySection } from './DirectorySection' import { GrokPermissionModeSelector } from './GrokPermissionModeSelector' +import { CodexFamilyPermissionModeSelector } from './CodexFamilyPermissionModeSelector' +import { CopilotAgentModeSelector } from './CopilotAgentModeSelector' import { FastModeSelector } from './FastModeSelector' import { MachineSelector } from './MachineSelector' import { ModelSelector } from './ModelSelector' @@ -61,6 +64,7 @@ import { } from './preferences' import { SessionTypeSelector } from './SessionTypeSelector' import { YoloToggle } from './YoloToggle' +import { usesCodexFamilyPermissionModes } from '@/lib/codexFamilyPermissionAgents' import { CodexSessionSyncDialog } from '@/components/CodexSessionSyncDialog' import { ConfirmDialog } from '@/components/ui/ConfirmDialog' import { formatRunnerSpawnError } from '../../utils/formatRunnerSpawnError' @@ -100,7 +104,9 @@ export function NewSession(props: { const [opencodeSelectedModel, setOpencodeSelectedModel] = useState(null) const [serviceTier, setServiceTier] = useState('standard') const [collaborationMode, setCollaborationMode] = useState('default') + const [copilotAgentMode, setCopilotAgentMode] = useState('interactive') const [yoloMode, setYoloMode] = useState(loadPreferredYoloMode) + const [codexFamilyPermissionMode, setCodexFamilyPermissionMode] = useState('default') const [grokPermissionMode, setGrokPermissionMode] = useState('default') const [sessionType, setSessionType] = useState('simple') const [worktreeName, setWorktreeName] = useState('') @@ -139,8 +145,10 @@ export function NewSession(props: { setEffort('auto') setModelReasoningEffort('default') setGrokPermissionMode('default') + setCodexFamilyPermissionMode('default') setServiceTier('standard') setCollaborationMode('default') + setCopilotAgentMode('interactive') if (agent !== 'cursor') { setModel('auto') setCursorSelectedBase('auto') @@ -208,7 +216,9 @@ export function NewSession(props: { ) setServiceTier(draft.serviceTier) setCollaborationMode(draft.collaborationMode) + setCopilotAgentMode(draft.copilotAgentMode) setYoloMode(draft.yoloMode) + setCodexFamilyPermissionMode(draft.codexFamilyPermissionMode) setGrokPermissionMode(draft.grokPermissionMode) setSessionType(draft.sessionType) setWorktreeName(draft.worktreeName) @@ -503,6 +513,24 @@ export function NewSession(props: { cwdExists: deferredDirectoryExists, }) }) + const copilotModelsState = useCopilotModelsForCwd({ + api: props.api, + machineId, + cwd: deferredDirectory, + enabled: agent === 'copilot' && deferredDirectoryExists === true + }) + const copilotModelOptions = useMemo( + () => [ + { value: 'auto', label: 'Auto' }, + ...copilotModelsState.availableModels + .filter((candidate) => candidate.modelId !== 'auto') + .map((candidate) => ({ + value: candidate.modelId, + label: candidate.name ?? candidate.modelId + })) + ], + [copilotModelsState.availableModels] + ) const grokModelOptions = useMemo( () => buildGrokModelOptions(grokModelsState.availableModels), [grokModelsState.availableModels] @@ -624,6 +652,25 @@ export function NewSession(props: { grokModelsState.isLoading, model ]) + useEffect(() => { + if ( + agent === 'copilot' + && deferredDirectoryExists === true + && !copilotModelsState.isLoading + && !copilotModelsState.error + && model !== 'auto' + && !copilotModelsState.availableModels.some((candidate) => candidate.modelId === model) + ) { + setModel('auto') + } + }, [ + agent, + copilotModelsState.availableModels, + copilotModelsState.error, + copilotModelsState.isLoading, + deferredDirectoryExists, + model + ]) const currentDirectoryExists = trimmedDirectory ? pathExistence[trimmedDirectory] : undefined const needsDirectoryCreationWarning = sessionType === 'simple' && trimmedDirectory !== '' && currentDirectoryExists === false @@ -962,7 +1009,9 @@ export function NewSession(props: { modelReasoningEffort, serviceTier, collaborationMode, + copilotAgentMode, yoloMode, + codexFamilyPermissionMode, grokPermissionMode, sessionType, worktreeName @@ -979,7 +1028,9 @@ export function NewSession(props: { modelReasoningEffort, serviceTier, collaborationMode, + copilotAgentMode, yoloMode, + codexFamilyPermissionMode, grokPermissionMode, sessionType, worktreeName, @@ -1100,6 +1151,8 @@ export function NewSession(props: { ? collaborationMode : undefined + const usesCodexFamilyPermissions = usesCodexFamilyPermissionModes(agent) + if (agent === 'codex' && selectedCodexImportSession) { setIsImportingCodexSession(true) const result = await props.api.syncCodexSession({ @@ -1110,7 +1163,7 @@ export function NewSession(props: { modelReasoningEffort: resolvedModelReasoningEffort ?? null, serviceTier: resolvedServiceTier, collaborationMode: resolvedCollaborationMode ?? 'default', - yolo: yoloMode + yolo: codexFamilyPermissionMode === 'yolo' }) if (result.success) { const importedSessionId = result.hapiSessionIds?.[0] @@ -1121,7 +1174,9 @@ export function NewSession(props: { // 这里立刻 resume,避免进入会话页时先看到离线,等首条消息才触发启动。 const resumedSessionId = await props.api.resumeSession( importedSessionId, - yoloMode ? { permissionMode: 'yolo' } : undefined + codexFamilyPermissionMode !== 'default' + ? { permissionMode: codexFamilyPermissionMode } + : undefined ) haptic.notification('success') markCodexSessionsImported([selectedCodexImportSession.id]) @@ -1145,12 +1200,17 @@ export function NewSession(props: { model: resolvedModel, effort: resolvedEffort, modelReasoningEffort: resolvedModelReasoningEffort, - yolo: agent === 'grok' ? undefined : yoloMode, - permissionMode: agent === 'grok' ? grokPermissionMode : undefined, + yolo: agent === 'grok' || usesCodexFamilyPermissions ? undefined : yoloMode, + permissionMode: agent === 'grok' + ? grokPermissionMode + : usesCodexFamilyPermissions + ? codexFamilyPermissionMode + : undefined, sessionType, worktreeName: sessionType === 'worktree' ? (worktreeName.trim() || undefined) : undefined, serviceTier: resolvedServiceTier, - collaborationMode: resolvedCollaborationMode + collaborationMode: resolvedCollaborationMode, + copilotAgentMode: agent === 'copilot' ? copilotAgentMode : undefined }) if (result.type === 'success') { @@ -1196,6 +1256,12 @@ export function NewSession(props: { deferredDirectoryExists === undefined || (deferredDirectoryExists === true && opencodeModelsState.isLoading) )) + || (agent === 'copilot' + && model !== 'auto' + && ( + deferredDirectoryExists === undefined + || (deferredDirectoryExists === true && copilotModelsState.isLoading) + )) const fastModeSelectionPending = agent === 'codex' && serviceTier === 'fast' && codexModelsState.isLoading @@ -1325,19 +1391,25 @@ export function NewSession(props: { ? codexModelOptions : agent === 'grok' ? grokModelOptions + : agent === 'copilot' + ? copilotModelOptions : undefined } isDisabled={ isFormDisabled || (agent === 'codex' && Boolean(codexModelsState.error)) || (agent === 'grok' && Boolean(grokModelsState.error)) + || (agent === 'copilot' && Boolean(copilotModelsState.error)) } isLoading={(agent === 'codex' && codexModelsState.isLoading) - || (agent === 'grok' && grokModelsState.isLoading)} + || (agent === 'grok' && grokModelsState.isLoading) + || (agent === 'copilot' && copilotModelsState.isLoading)} error={agent === 'codex' && codexModelsState.error ? `${t('newSession.model.loadFailed')}: ${codexModelsState.error}` : agent === 'grok' && grokModelsState.error ? `${t('newSession.model.loadFailed')}: ${grokModelsState.error}` + : agent === 'copilot' && copilotModelsState.error + ? `${t('newSession.model.loadFailed')}: ${copilotModelsState.error}` : null} onModelChange={setModel} /> @@ -1364,19 +1436,31 @@ export function NewSession(props: { isDisabled={isFormDisabled} onChange={setGrokPermissionMode} /> + + - {agent !== 'grok' ? ( + {agent !== 'grok' && !usesCodexFamilyPermissionModes(agent) ? ( { modelReasoningEffort: 'default', serviceTier: 'standard', collaborationMode: 'default', + copilotAgentMode: 'interactive', yoloMode: false, + codexFamilyPermissionMode: 'default', grokPermissionMode: 'default', sessionType: 'simple', worktreeName: '' @@ -37,7 +39,9 @@ describe('newSessionFormDraft', () => { modelReasoningEffort: 'default', serviceTier: 'standard', collaborationMode: 'default', + copilotAgentMode: 'interactive', yoloMode: false, + codexFamilyPermissionMode: 'default', grokPermissionMode: 'default', sessionType: 'simple', worktreeName: '' @@ -65,7 +69,9 @@ describe('newSessionFormDraft', () => { modelReasoningEffort: 'default', serviceTier: 'standard', collaborationMode: 'default', + copilotAgentMode: 'interactive', yoloMode: false, + codexFamilyPermissionMode: 'default', grokPermissionMode: 'default', sessionType: 'simple', worktreeName: '' @@ -84,7 +90,9 @@ describe('newSessionFormDraft', () => { modelReasoningEffort: 'default', serviceTier: 'fast', collaborationMode: 'plan', + copilotAgentMode: 'interactive', yoloMode: false, + codexFamilyPermissionMode: 'default', grokPermissionMode: 'default', sessionType: 'simple', worktreeName: '' @@ -105,7 +113,9 @@ describe('newSessionFormDraft', () => { modelReasoningEffort: 'high', serviceTier: 'fast', collaborationMode: 'plan', + copilotAgentMode: 'interactive', yoloMode: true, + codexFamilyPermissionMode: 'default', grokPermissionMode: 'default', sessionType: 'simple', worktreeName: '' @@ -124,4 +134,25 @@ describe('newSessionFormDraft', () => { expect(loaded.yoloMode).toBe(true) expect(loaded.machineId).toBe('machine-1') }) + + it('maps legacy yoloMode to codex-family permission mode when restoring copilot drafts', () => { + sessionStorage.setItem('hapi:new-session-form-draft', JSON.stringify({ + agent: 'copilot', + model: 'auto', + cursorSelectedBase: 'auto', + machineId: 'machine-1', + effort: 'auto', + modelReasoningEffort: 'default', + serviceTier: 'standard', + collaborationMode: 'default', + copilotAgentMode: 'interactive', + yoloMode: true, + sessionType: 'simple', + worktreeName: '' + })) + + const loaded = loadNewSessionFormDraft()! + expect(loaded.agent).toBe('copilot') + expect(loaded.codexFamilyPermissionMode).toBe('yolo') + }) }) diff --git a/web/src/components/NewSession/newSessionFormDraft.ts b/web/src/components/NewSession/newSessionFormDraft.ts index 8d8d3dcf..9734567b 100644 --- a/web/src/components/NewSession/newSessionFormDraft.ts +++ b/web/src/components/NewSession/newSessionFormDraft.ts @@ -1,8 +1,12 @@ import { CREATABLE_AGENT_FLAVORS, GROK_PERMISSION_MODES, + getPermissionModesForFlavor, + normalizeCopilotAgentMode, type CodexCollaborationMode, - type GrokPermissionMode + type CopilotAgentMode, + type GrokPermissionMode, + type PermissionMode } from '@hapi/protocol' import type { AgentType, LaunchEffort, CodexReasoningEffort, NewSessionServiceTier, SessionType } from './types' @@ -17,7 +21,9 @@ export type NewSessionFormDraft = { modelReasoningEffort: CodexReasoningEffort serviceTier: NewSessionServiceTier collaborationMode: CodexCollaborationMode + copilotAgentMode: CopilotAgentMode yoloMode: boolean + codexFamilyPermissionMode: PermissionMode grokPermissionMode: GrokPermissionMode sessionType: SessionType worktreeName: string @@ -62,7 +68,21 @@ export function loadNewSessionFormDraft(): NewSessionFormDraft | null { : 'default', serviceTier: agentPreserved && parsed.serviceTier === 'fast' ? 'fast' : 'standard', collaborationMode: agentPreserved && parsed.collaborationMode === 'plan' ? 'plan' : 'default', + copilotAgentMode: agentPreserved + ? normalizeCopilotAgentMode(parsed.copilotAgentMode) + : 'interactive', yoloMode: Boolean(parsed.yoloMode), + codexFamilyPermissionMode: (() => { + const modes = getPermissionModesForFlavor(restoredAgent) + const parsedMode = parsed.codexFamilyPermissionMode as PermissionMode | undefined + if (agentPreserved && parsedMode && modes.includes(parsedMode)) { + return parsedMode + } + if (agentPreserved && parsed.yoloMode && modes.includes('yolo')) { + return 'yolo' + } + return 'default' + })(), grokPermissionMode: agentPreserved && GROK_PERMISSION_MODES.includes(parsed.grokPermissionMode as GrokPermissionMode) ? parsed.grokPermissionMode as GrokPermissionMode diff --git a/web/src/components/NewSession/preferences.ts b/web/src/components/NewSession/preferences.ts index 11c024dc..1ba07727 100644 --- a/web/src/components/NewSession/preferences.ts +++ b/web/src/components/NewSession/preferences.ts @@ -120,7 +120,7 @@ export function resolvePreferredLaunchSettings( ): PreferredLaunchSettings { const preferredModel = preferred?.model ?? 'auto' const staticModelValues = MODEL_OPTIONS[agent].map((option) => option.value) - const model = staticModelValues.length > 0 && agent !== 'codex' + const model = staticModelValues.length > 0 && agent !== 'codex' && agent !== 'copilot' ? resolvePreferredOptionValue(preferredModel, staticModelValues, 'auto') : preferredModel const effort = agent === 'claude' diff --git a/web/src/components/NewSession/types.ts b/web/src/components/NewSession/types.ts index 2254a325..a4be34f3 100644 --- a/web/src/components/NewSession/types.ts +++ b/web/src/components/NewSession/types.ts @@ -36,6 +36,9 @@ export const MODEL_OPTIONS: Record ( + agentFlavor === 'copilot' + ? [ + { value: null, label: 'Auto' }, + ...copilotModelsState.availableModels + .filter((model) => model.modelId !== 'auto') + .map((model) => ({ + value: model.modelId, + label: model.name ?? model.modelId + })) + ] + : undefined + ), [agentFlavor, copilotModelsState.availableModels]) const cursorModelsState = useCursorModels({ api: props.api, sessionId: props.session.id, @@ -959,6 +979,7 @@ function SessionChatInner(props: SessionChatProps) { switchSession, setPermissionMode, setCollaborationMode, + setCopilotAgentMode, setModel, setModelReasoningEffort, setEffort, @@ -1233,6 +1254,17 @@ function SessionChatInner(props: SessionChatProps) { } }, [setCollaborationMode, props.onRefresh, haptic]) + const handleCopilotAgentModeChange = useCallback(async (mode: CopilotAgentMode) => { + try { + await setCopilotAgentMode(mode) + haptic.notification('success') + props.onRefresh() + } catch (e) { + haptic.notification('error') + console.error('Failed to set Copilot agent mode:', e) + } + }, [setCopilotAgentMode, props.onRefresh, haptic]) + // Model mode change handler const handleModelChange = useCallback(async (model: SessionModelSelection) => { const previousModelReasoningEffort = props.session.modelReasoningEffort @@ -1604,6 +1636,7 @@ function SessionChatInner(props: SessionChatProps) { onClearSchedule={() => updatePendingSchedule(null)} permissionMode={props.session.permissionMode} collaborationMode={codexCollaborationModeSupported ? props.session.collaborationMode : undefined} + copilotAgentMode={agentFlavor === 'copilot' ? props.session.copilotAgentMode : undefined} model={props.session.model} modelReasoningEffort={agentFlavor === 'codex' || agentFlavor === 'opencode' ? props.session.modelReasoningEffort : undefined} effort={props.session.effort} @@ -1623,6 +1656,8 @@ function SessionChatInner(props: SessionChatProps) { ? opencodeModelOptions : agentFlavor === 'grok' ? grokModelOptions + : agentFlavor === 'copilot' + ? copilotModelOptions // Pi uses its own provider-qualified picker (piModels prop). // Feeding piModelOptions here would make the generic Ctrl/Cmd+M // cycler (getNextModelForFlavor) post a bare modelId string, @@ -1660,7 +1695,16 @@ function SessionChatInner(props: SessionChatProps) { ? handleCollaborationModeChange : undefined } - onPermissionModeChange={handlePermissionModeChange} + onCopilotAgentModeChange={ + agentFlavor === 'copilot' && props.session.active && !controlledByUser + ? handleCopilotAgentModeChange + : undefined + } + onPermissionModeChange={ + agentFlavor === 'copilot' && controlledByUser + ? undefined + : handlePermissionModeChange + } selectedModelBase={ agentFlavor === 'cursor' && cursorPicker?.mode === 'dual' ? cursorSelectedBaseValue @@ -1698,6 +1742,10 @@ function SessionChatInner(props: SessionChatProps) { ? (props.session.active && !controlledByUser && !grokModelsState.error ? handleModelChange : undefined) + : agentFlavor === 'copilot' + ? (props.session.active && !controlledByUser + ? handleModelChange + : undefined) : handleModelChange } onModelEffortChange={ diff --git a/web/src/components/ToolCard/PermissionFooter.tsx b/web/src/components/ToolCard/PermissionFooter.tsx index 873c9c32..6d6a0d56 100644 --- a/web/src/components/ToolCard/PermissionFooter.tsx +++ b/web/src/components/ToolCard/PermissionFooter.tsx @@ -28,6 +28,7 @@ function isCodexSession(metadata: SessionMetadataSummary | null, toolName: strin || toolName.startsWith('Codex') || toolName.startsWith('Gemini') || toolName.startsWith('OpenCode') + || toolName.startsWith('Copilot') || toolName.startsWith('Cursor') } diff --git a/web/src/components/icons/CopilotIcon.tsx b/web/src/components/icons/CopilotIcon.tsx new file mode 100644 index 00000000..1f3d0e93 --- /dev/null +++ b/web/src/components/icons/CopilotIcon.tsx @@ -0,0 +1,20 @@ +type CopilotIconProps = { + size?: string | number + className?: string +} + +/** GitHub mark — used for Copilot CLI until @lobehub/icons ships a Copilot asset. */ +export function CopilotIcon({ size = '100%', className }: CopilotIconProps) { + return ( + + ) +} diff --git a/web/src/hooks/mutations/useSessionActions.ts b/web/src/hooks/mutations/useSessionActions.ts index 58cd17e8..acf6f950 100644 --- a/web/src/hooks/mutations/useSessionActions.ts +++ b/web/src/hooks/mutations/useSessionActions.ts @@ -1,7 +1,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' import { isPermissionModeAllowedForFlavor } from '@hapi/protocol' import type { ApiClient } from '@/api/client' -import type { CodexCollaborationMode, PermissionMode, SessionResponse, SessionsResponse } from '@/types/api' +import type { CodexCollaborationMode, CopilotAgentMode, PermissionMode, SessionResponse, SessionsResponse } from '@/types/api' import type { ReopenSessionResponse } from '@hapi/protocol/apiTypes' import { queryKeys } from '@/lib/query-keys' import { clearMessageWindow } from '@/lib/message-window-store' @@ -19,6 +19,7 @@ export function useSessionActions( switchSession: () => Promise setPermissionMode: (mode: PermissionMode) => Promise setCollaborationMode: (mode: CodexCollaborationMode) => Promise + setCopilotAgentMode: (mode: CopilotAgentMode) => Promise setModel: (model: { provider: string; modelId: string } | string | null) => Promise setModelReasoningEffort: (modelReasoningEffort: string | null) => Promise setEffort: (effort: string | null) => Promise @@ -145,6 +146,19 @@ export function useSessionActions( onSuccess: () => void invalidateSession(), }) + const copilotAgentModeMutation = useMutation({ + mutationFn: async (mode: CopilotAgentMode) => { + if (!api || !sessionId) { + throw new Error('Session unavailable') + } + if (agentFlavor !== 'copilot') { + throw new Error('Agent mode is only supported for Copilot sessions') + } + await api.setCopilotAgentMode(sessionId, mode) + }, + onSuccess: () => void invalidateSession(), + }) + const modelMutation = useMutation({ mutationFn: async (model: { provider: string; modelId: string } | string | null) => { if (!api || !sessionId) { @@ -234,6 +248,7 @@ export function useSessionActions( switchSession: switchMutation.mutateAsync, setPermissionMode: permissionMutation.mutateAsync, setCollaborationMode: collaborationMutation.mutateAsync, + setCopilotAgentMode: copilotAgentModeMutation.mutateAsync, setModel: modelMutation.mutateAsync, setModelReasoningEffort: modelReasoningEffortMutation.mutateAsync, setEffort: effortMutation.mutateAsync, @@ -246,6 +261,7 @@ export function useSessionActions( || switchMutation.isPending || permissionMutation.isPending || collaborationMutation.isPending + || copilotAgentModeMutation.isPending || modelMutation.isPending || modelReasoningEffortMutation.isPending || effortMutation.isPending diff --git a/web/src/hooks/mutations/useSpawnSession.ts b/web/src/hooks/mutations/useSpawnSession.ts index c24becbf..851d2cbb 100644 --- a/web/src/hooks/mutations/useSpawnSession.ts +++ b/web/src/hooks/mutations/useSpawnSession.ts @@ -1,5 +1,5 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' -import type { AgentFlavor, CodexCollaborationMode, PermissionMode } from '@hapi/protocol' +import type { AgentFlavor, CodexCollaborationMode, CopilotAgentMode, PermissionMode } from '@hapi/protocol' import type { ApiClient } from '@/api/client' import type { SpawnResponse } from '@/types/api' import { queryKeys } from '@/lib/query-keys' @@ -17,6 +17,7 @@ type SpawnInput = { worktreeName?: string serviceTier?: 'fast' | 'standard' collaborationMode?: CodexCollaborationMode + copilotAgentMode?: CopilotAgentMode } export function useSpawnSession(api: ApiClient | null): { @@ -43,7 +44,8 @@ export function useSpawnSession(api: ApiClient | null): { input.effort, input.permissionMode, input.serviceTier, - input.collaborationMode + input.collaborationMode, + input.copilotAgentMode ) }, onSuccess: () => { diff --git a/web/src/hooks/queries/useCopilotModels.ts b/web/src/hooks/queries/useCopilotModels.ts new file mode 100644 index 00000000..a1a2a380 --- /dev/null +++ b/web/src/hooks/queries/useCopilotModels.ts @@ -0,0 +1,38 @@ +import { useQuery } from '@tanstack/react-query' +import type { ApiClient } from '@/api/client' +import type { CopilotModelSummary } from '@/types/api' +import { queryKeys } from '@/lib/query-keys' + +export function useCopilotModels(args: { + api: ApiClient | null + sessionId?: string | null + enabled?: boolean +}): { + availableModels: CopilotModelSummary[] + currentModelId: string | null + isLoading: boolean + error: string | null +} { + const enabled = Boolean(args.enabled && args.api && args.sessionId) + const query = useQuery({ + queryKey: args.sessionId + ? queryKeys.sessionCopilotModels(args.sessionId) + : ['session-copilot-models', 'unknown'] as const, + queryFn: async () => { + if (!args.api || !args.sessionId) throw new Error('Copilot session unavailable') + return await args.api.getSessionCopilotModels(args.sessionId) + }, + enabled, + staleTime: 30_000, + retry: 1, + }) + + return { + availableModels: query.data?.availableModels ?? [], + currentModelId: query.data?.currentModelId ?? null, + isLoading: query.isLoading, + error: query.data?.success === false + ? (query.data.error ?? 'Failed to load Copilot models') + : query.error instanceof Error ? query.error.message : null, + } +} diff --git a/web/src/hooks/queries/useCopilotModelsForCwd.ts b/web/src/hooks/queries/useCopilotModelsForCwd.ts new file mode 100644 index 00000000..e89ac326 --- /dev/null +++ b/web/src/hooks/queries/useCopilotModelsForCwd.ts @@ -0,0 +1,48 @@ +import { useQuery } from '@tanstack/react-query' +import type { ApiClient } from '@/api/client' +import type { CopilotModelSummary } from '@/types/api' +import { queryKeys } from '@/lib/query-keys' + +export function useCopilotModelsForCwd(args: { + api: ApiClient | null + machineId?: string | null + cwd?: string | null + enabled?: boolean +}): { + availableModels: CopilotModelSummary[] + currentModelId: string | null + isLoading: boolean + error: string | null +} { + const { api, machineId, cwd } = args + const trimmedCwd = typeof cwd === 'string' ? cwd.trim() : '' + const enabled = Boolean(args.enabled && api && machineId && trimmedCwd) + + const query = useQuery({ + queryKey: machineId && trimmedCwd + ? queryKeys.machineCopilotModelsForCwd(machineId, trimmedCwd) + : ['machine-copilot-models', 'unknown', 'unknown'] as const, + queryFn: async () => { + if (!api || !machineId || !trimmedCwd) { + throw new Error('Copilot models target unavailable') + } + return await api.getMachineCopilotModelsForCwd(machineId, trimmedCwd) + }, + enabled, + staleTime: 60_000, + retry: false, + }) + + return { + availableModels: query.data?.availableModels ?? [], + currentModelId: query.data?.currentModelId ?? null, + isLoading: query.isLoading, + error: query.data?.success === false + ? (query.data.error ?? 'Failed to load Copilot models') + : query.error instanceof Error + ? query.error.message + : query.error + ? 'Failed to load Copilot models' + : null, + } +} diff --git a/web/src/lib/codexFamilyPermissionAgents.ts b/web/src/lib/codexFamilyPermissionAgents.ts new file mode 100644 index 00000000..003ff67a --- /dev/null +++ b/web/src/lib/codexFamilyPermissionAgents.ts @@ -0,0 +1,19 @@ +import type { AgentFlavor } from '@hapi/protocol' + +/** Agents that share codex-family permission modes (default / read-only / safe-yolo / yolo). */ +export const CODEX_FAMILY_PERMISSION_AGENTS = [ + 'codex', + 'gemini', + 'kimi', + 'copilot', + 'opencode' +] as const satisfies readonly AgentFlavor[] + +export type CodexFamilyPermissionAgent = typeof CODEX_FAMILY_PERMISSION_AGENTS[number] + +export function usesCodexFamilyPermissionModes( + flavor: string | null | undefined +): flavor is CodexFamilyPermissionAgent { + return typeof flavor === 'string' + && (CODEX_FAMILY_PERMISSION_AGENTS as readonly string[]).includes(flavor) +} diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index ea9e633b..f0da20fe 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -288,6 +288,7 @@ export default { 'newSession.opencodeModel.default': 'Default', 'newSession.reasoningEffort': 'Reasoning effort', 'newSession.collaborationMode': 'Collaboration mode', + 'newSession.copilotAgentMode': 'Agent mode', 'newSession.fastMode': 'Fast mode', 'newSession.yolo': 'YOLO mode', 'newSession.yolo.title': 'Bypass approvals and sandbox', @@ -937,6 +938,7 @@ export default { 'misc.noMachines': 'No machines available', 'misc.machine': 'Machine', 'misc.collaborationMode': 'Collaboration Mode', + 'misc.copilotAgentMode': 'Agent Mode', 'misc.permissionMode': 'Permission Mode', 'misc.model': 'Model', 'misc.reasoningEffort': 'Reasoning Effort', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index 01c49c15..4a4ec012 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -292,6 +292,7 @@ export default { 'newSession.opencodeModel.default': '默认', 'newSession.reasoningEffort': '推理强度', 'newSession.collaborationMode': '协作模式', + 'newSession.copilotAgentMode': '代理模式', 'newSession.fastMode': '快速模式', 'newSession.yolo': 'YOLO 模式', 'newSession.yolo.title': '跳过审批和沙箱', @@ -941,6 +942,7 @@ export default { 'misc.noMachines': '无可用机器', 'misc.machine': '机器', 'misc.collaborationMode': '协作模式', + 'misc.copilotAgentMode': '代理模式', 'misc.permissionMode': '权限模式', 'misc.model': '模型', 'misc.reasoningEffort': '推理强度', diff --git a/web/src/lib/query-keys.ts b/web/src/lib/query-keys.ts index 08dfbdf3..87bc974d 100644 --- a/web/src/lib/query-keys.ts +++ b/web/src/lib/query-keys.ts @@ -26,6 +26,8 @@ export const queryKeys = { machineOpencodeModelsForCwd: (machineId: string, cwd: string) => ['machine-opencode-models', machineId, cwd] as const, machineGrokModelsForCwd: (machineId: string, cwd: string) => ['machine-grok-models', machineId, cwd] as const, sessionGrokModels: (sessionId: string) => ['session-grok-models', sessionId] as const, + sessionCopilotModels: (sessionId: string) => ['session-copilot-models', sessionId] as const, + machineCopilotModelsForCwd: (machineId: string, cwd: string) => ['machine-copilot-models', machineId, cwd] as const, sessionGrokReasoningEffortOptions: (sessionId: string) => ['session-grok-reasoning-effort-options', sessionId] as const, skills: (sessionId: string) => ['skills', sessionId] as const, scratchlist: (sessionId: string) => ['scratchlist', sessionId] as const, diff --git a/web/src/lib/sessionResume.test.ts b/web/src/lib/sessionResume.test.ts index 076c2d3a..4bfac738 100644 --- a/web/src/lib/sessionResume.test.ts +++ b/web/src/lib/sessionResume.test.ts @@ -286,6 +286,12 @@ describe('sessionResume — regression for all other flavor ids', () => { path: '/p', host: 'h', flavor: 'kimi', kimiSessionId: 'ki-1', })).toBe('ki-1') }) + + it('copilot', () => { + expect(resolveAgentSessionIdFromMetadata({ + path: '/p', host: 'h', flavor: 'copilot', copilotSessionId: 'cp-1', + })).toBe('cp-1') + }) it('claude (default branch)', () => { expect(resolveAgentSessionIdFromMetadata({ path: '/p', host: 'h', flavor: 'claude', claudeSessionId: 'cl-1', diff --git a/web/src/lib/sessionResume.ts b/web/src/lib/sessionResume.ts index 652cd733..aaca6b14 100644 --- a/web/src/lib/sessionResume.ts +++ b/web/src/lib/sessionResume.ts @@ -19,6 +19,7 @@ export function resolveAgentSessionIdFromMetadata( case 'grok': return metadata.grokSessionId ?? undefined case 'cursor': return metadata.cursorSessionId ?? undefined case 'kimi': return metadata.kimiSessionId ?? undefined + case 'copilot': return metadata.copilotSessionId ?? undefined case 'pi': return metadata.piSessionId ?? undefined default: return metadata.claudeSessionId ?? undefined } diff --git a/web/src/router.tsx b/web/src/router.tsx index 99ccd152..66dc44ca 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -656,11 +656,14 @@ function SessionPage() { }) const fileHits: Suggestion[] = [] - if (agentType === 'codex' && api && sessionId) { + if ((agentType === 'codex' || agentType === 'copilot') && api && sessionId) { const response = await api.searchSessionFiles(sessionId, search, 50) if (response.success && response.files) { for (const file of response.files) { - const mentionText = `@"${file.fullPath.replace(/(["\\])/g, '\\$1')}"` + // Codex App Server expects @"path"; Copilot CLI uses @path (relative preferred). + const mentionText = agentType === 'copilot' + ? `@${file.fullPath}` + : `@"${file.fullPath.replace(/(["\\])/g, '\\$1')}"` fileHits.push({ key: mentionText, text: mentionText, diff --git a/web/src/types/api.ts b/web/src/types/api.ts index 0b5d9bf7..6b51ab17 100644 --- a/web/src/types/api.ts +++ b/web/src/types/api.ts @@ -1,5 +1,6 @@ import type { CodexCollaborationMode, + CopilotAgentMode, DecryptedMessage as ProtocolDecryptedMessage, Machine, RunnerState, @@ -22,6 +23,8 @@ export type { GitCommandResponse, GrokModelsResponse, GrokModelSummary, + CopilotModelsResponse, + CopilotModelSummary, GrokReasoningEffortResponse, GrokReasoningEffortOption, ListDirectoryResponse, @@ -49,6 +52,7 @@ export type { AgentState, AttachmentMetadata, CodexCollaborationMode, + CopilotAgentMode, Metadata, PermissionMode, Machine,