mirror of
https://github.com/wu736139669/hapi.git
synced 2026-11-08 01:43:07 +00:00
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:
co-authored by
Cursor
HAPI
parent
b67f4e56e5
commit
f10fbc7496
@@ -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' };
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { AcpSdkBackend } from '@/agent/backends/acp';
|
||||
import type { CopilotAgentMode } from '@hapi/protocol';
|
||||
|
||||
function filterEnv(env: NodeJS.ProcessEnv): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
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)
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<void>;
|
||||
cleanup: () => Promise<void>;
|
||||
};
|
||||
|
||||
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<string>();
|
||||
private interval: ReturnType<typeof setInterval> | null = null;
|
||||
private scanPromise: Promise<void> | 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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<LocatedCopilotSession[]> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<PermissionResponseMessage, void> {
|
||||
private readonly pendingBackendRequests = new Map<string, PermissionRequest>();
|
||||
|
||||
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<void> {
|
||||
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<void>
|
||||
): Promise<PermissionCompletion> {
|
||||
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<void> {
|
||||
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'
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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' });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 <task>` (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 <task>`.'
|
||||
};
|
||||
}
|
||||
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' };
|
||||
}
|
||||
Reference in New Issue
Block a user