mirror of
https://github.com/wu736139669/hapi.git
synced 2026-11-08 01:43:07 +00:00
* feat(cli): add GitHub Copilot CLI agent support via ACP 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>
191 lines
6.5 KiB
TypeScript
191 lines
6.5 KiB
TypeScript
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'
|
|
});
|
|
}
|
|
}
|