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
+196
View File
@@ -0,0 +1,196 @@
import React, { useEffect, useState } from 'react';
import { Box, Text, useStdout } from 'ink';
import { MessageBuffer, type BufferedMessage } from './messageBuffer';
import { useSwitchControls } from './useSwitchControls';
interface CopilotDisplayProps {
messageBuffer: MessageBuffer;
logPath?: string;
onExit?: () => void;
onSwitchToLocal?: () => void;
}
function extractTag(messages: BufferedMessage[], tag: 'MODEL' | 'MODE' | 'AGENT_MODE'): string | null {
const prefix = `[${tag}:`;
for (let index = messages.length - 1; index >= 0; index -= 1) {
const message = messages[index];
if (message.type !== 'system') {
continue;
}
if (!message.content.startsWith(prefix)) {
continue;
}
const match = message.content.match(/\[\w+:(.+?)\]/);
if (match && match[1]) {
return match[1];
}
}
return null;
}
export const CopilotDisplay: React.FC<CopilotDisplayProps> = ({
messageBuffer,
logPath,
onExit,
onSwitchToLocal
}) => {
const [messages, setMessages] = useState<BufferedMessage[]>([]);
const [model, setModel] = useState<string | null>(null);
const [permissionMode, setPermissionMode] = useState<string | null>(null);
const [agentMode, setAgentMode] = useState<string | null>(null);
const { confirmationMode, actionInProgress } = useSwitchControls({
onExit,
onSwitch: onSwitchToLocal
});
const { stdout } = useStdout();
const terminalWidth = stdout.columns || 80;
const terminalHeight = stdout.rows || 24;
useEffect(() => {
setMessages(messageBuffer.getMessages());
const unsubscribe = messageBuffer.onUpdate((newMessages) => {
setMessages(newMessages);
const nextModel = extractTag(newMessages, 'MODEL');
if (nextModel) {
setModel(nextModel);
}
const nextMode = extractTag(newMessages, 'MODE');
if (nextMode) {
setPermissionMode(nextMode);
}
const nextAgentMode = extractTag(newMessages, 'AGENT_MODE');
if (nextAgentMode) {
setAgentMode(nextAgentMode);
}
});
return () => {
unsubscribe();
};
}, [messageBuffer]);
const getMessageColor = (type: BufferedMessage['type']): string => {
switch (type) {
case 'user': return 'magenta';
case 'assistant': return 'cyan';
case 'system': return 'blue';
case 'tool': return 'yellow';
case 'result': return 'green';
case 'status': return 'gray';
default: return 'white';
}
};
const formatMessage = (msg: BufferedMessage): string => {
const lines = msg.content.split('\n');
const maxLineLength = Math.max(1, terminalWidth - 10);
return lines.map(line => {
if (line.length <= maxLineLength) return line;
const chunks: string[] = [];
for (let i = 0; i < line.length; i += maxLineLength) {
chunks.push(line.slice(i, i + maxLineLength));
}
return chunks.join('\n');
}).join('\n');
};
const visibleMessages = messages.filter((msg) => {
if (msg.type === 'system' && msg.content.startsWith('[MODEL:')) {
return false;
}
if (msg.type === 'system' && msg.content.startsWith('[MODE:')) {
return false;
}
if (msg.type === 'system' && msg.content.startsWith('[AGENT_MODE:')) {
return false;
}
return true;
});
return (
<Box flexDirection="column" width={terminalWidth} height={terminalHeight}>
<Box
flexDirection="column"
width={terminalWidth}
height={terminalHeight - 4}
borderStyle="round"
borderColor="gray"
paddingX={1}
overflow="hidden"
>
<Box flexDirection="column" marginBottom={1}>
<Text color="gray" bold>Copilot Agent Messages</Text>
<Text color="gray" dimColor>{'-'.repeat(Math.min(terminalWidth - 4, 60))}</Text>
</Box>
<Box flexDirection="column" height={terminalHeight - 10} overflow="hidden">
{visibleMessages.length === 0 ? (
<Text color="gray" dimColor>Waiting for messages...</Text>
) : (
visibleMessages
.slice(-Math.max(1, terminalHeight - 10))
.map((msg) => (
<Box key={msg.id} flexDirection="column" marginBottom={1}>
<Text color={getMessageColor(msg.type)} dimColor>
{formatMessage(msg)}
</Text>
</Box>
))
)}
</Box>
</Box>
<Box
width={terminalWidth}
borderStyle="round"
borderColor={
actionInProgress ? 'gray' :
confirmationMode === 'exit' ? 'red' :
confirmationMode === 'switch' ? 'yellow' :
'green'
}
paddingX={2}
justifyContent="center"
alignItems="center"
flexDirection="column"
>
<Box flexDirection="column" alignItems="center">
{actionInProgress === 'exiting' ? (
<Text color="gray" bold>
Exiting agent...
</Text>
) : actionInProgress === 'switching' ? (
<Text color="gray" bold>
Switching to local mode...
</Text>
) : confirmationMode === 'exit' ? (
<Text color="red" bold>
Press Ctrl-C again to exit the agent
</Text>
) : confirmationMode === 'switch' ? (
<Text color="yellow" bold>
Press space again to switch to local mode
</Text>
) : (
<Text color="green" bold>
Copilot running {onSwitchToLocal ? '(Space to switch to local, Ctrl-C to exit)' : '(Ctrl-C to exit)'}
</Text>
)}
{(model || permissionMode || agentMode) && (
<Text color="gray" dimColor>
{model ? `Model: ${model}` : 'Model: auto'}
{agentMode ? ` | Mode: ${agentMode}` : ''}
{permissionMode ? ` | Permission: ${permissionMode}` : ''}
</Text>
)}
{process.env.DEBUG && logPath && (
<Text color="gray" dimColor>
Debug logs: {logPath}
</Text>
)}
</Box>
</Box>
</Box>
);
};