mirror of
https://github.com/wu736139669/hapi.git
synced 2026-11-15 03:01:39 +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,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');
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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