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
@@ -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<void> }> = [];
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();
});
});