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
+22
View File
@@ -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');
});
});
+49
View File
@@ -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<void> {
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
});
}
+75
View File
@@ -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();
}
}
@@ -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<void>;
setModel?: (sessionId: string, model: string) => Promise<void>;
setConfigOption?: (sessionId: string, configId: string, value: string) => Promise<void>;
getConfigOptionByCategory?: (sessionId: string, category: string) => {
id: string;
options: Array<{ value: string }>;
} | undefined;
} | null;
activeSessionId: string | null;
currentAgentMode: string;
displayAgentMode: string | null;
applyInitialAgentMode: () => Promise<void>;
currentBackendModel: string | null;
applyQueuedModel: (model: string) => Promise<string | null>;
handleAgentMessage: (message: AgentMessage) => void;
};
function createLauncher(
setMode: (sessionId: string, mode: string) => Promise<void>,
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');
});
});
+485
View File
@@ -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<typeof createCopilotBackend> | 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<string, string>();
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<RemoteLauncherExitReason> {
return this.start({
onExit: () => this.handleExitFromUi(),
onSwitchToLocal: () => this.handleSwitchFromUi()
});
}
protected createDisplay(context: RemoteLauncherDisplayContext): React.ReactElement {
return React.createElement(CopilotDisplay, context);
}
protected async runMainLoop(): Promise<void> {
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<void> {
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<typeof createCopilotBackend>,
sessionId: string,
model: string
): Promise<string | null> {
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<void> {
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<void> {
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<string | null> {
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<void> {
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<void> {
await this.requestExit('exit', () => this.handleAbort());
}
private async handleSwitchFromUi(): Promise<void> {
await this.requestExit('switch', () => this.handleAbort());
}
private async handleSwitchRequest(): Promise<void> {
await this.requestExit('switch', () => this.handleAbort());
}
}
function toAcpMcpServers(config: Record<string, { command: string; args: string[] }>): 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();
}
+66
View File
@@ -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<CopilotMode>;
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<void> {
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
});
}
+25
View File
@@ -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');
});
});
+355
View File
@@ -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<import('@hapi/protocol').CopilotAgentMode> {
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<void> {
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<CopilotMode>((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<string>();
const cancelledBeforeEnqueue = new Set<string>();
let userMessageChain: Promise<void> = 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<string, unknown> = {};
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();
}
}
+117
View File
@@ -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<CopilotMode> {
readonly startedBy: 'runner' | 'terminal';
readonly startingMode: 'local' | 'remote';
localLaunchFailure: LocalLaunchFailure | null = null;
private remoteAgentModeApplier: ((mode: CopilotAgentMode) => Promise<void>) | null = null;
constructor(opts: {
api: ApiClient;
client: ApiSessionClient;
path: string;
logPath: string;
sessionId: string | null;
messageQueue: MessageQueue2<CopilotMode>;
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<void>) | null): void => {
this.remoteAgentModeApplier = applier;
};
applyRemoteAgentMode = async (mode: CopilotAgentMode): Promise<void> => {
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<ApiSessionClient['sendSessionEvent']>[0]): void => {
this.client.sendSessionEvent(event);
};
}
+10
View File
@@ -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;
}
+10
View File
@@ -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();
});
});
+29
View File
@@ -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();
});
});
+190
View File
@@ -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' });
}
});
});
+222
View File
@@ -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' };
}