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 <noreply@hapi.run>
Co-authored-by: Cursor <cursoragent@cursor.com>

* 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 <cursoragent@cursor.com>

* chore: re-trigger Codex PR review after auth outage

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore: retry Codex PR review

Co-authored-by: Cursor <cursoragent@cursor.com>

* 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 <cursoragent@cursor.com>

* 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 <cursoragent@cursor.com>

* 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 <cursoragent@cursor.com>

* 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 <cursoragent@cursor.com>

* fix(copilot): preserve resume and auto semantics

Deduplicate Copilot resume rows, apply Auto explicitly, and fail closed on denied permissions.

Co-authored-by: Cursor <cursoragent@cursor.com>

* 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 <cursoragent@cursor.com>

* 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 <cursoragent@cursor.com>

* 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 <cursoragent@cursor.com>

* 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 <noreply@hapi.run>
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
SSU-WEI HUANG
2026-08-04 08:19:12 +08:00
committed by GitHub
co-authored by Cursor HAPI
parent b67f4e56e5
commit f10fbc7496
98 changed files with 4255 additions and 56 deletions
+66
View File
@@ -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<unknown> {
const manager = (client as unknown as { rpcHandlerManager: { handleRequest: (req: { method: string; params: string }) => Promise<string> } }).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
+22 -1
View File
@@ -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<ListCopilotModelsForCwdRequest, ListCopilotModelsForCwdResponse>(
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<unknown, ListCodexSessionsRpcResponse>(
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,
+1
View File
@@ -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') {