diff --git a/src/components/permission-prompt.tsx b/src/components/permission-prompt.tsx new file mode 100644 index 0000000..496a34a --- /dev/null +++ b/src/components/permission-prompt.tsx @@ -0,0 +1,57 @@ +import React from 'react'; +import { Box, Text } from 'ink'; +import type { PermissionRequest } from '../services/copilot.js'; + +interface PermissionPromptProps { + request: PermissionRequest; + taskId?: string; +} + +const KIND_LABEL: Record = { + shell: 'Run shell command', + write: 'Write to filesystem', + read: 'Read from filesystem', + mcp: 'Call MCP server tool', + url: 'Fetch URL', +}; + +export default function PermissionPrompt({ request, taskId }: PermissionPromptProps): React.ReactElement { + const kindLabel = KIND_LABEL[request.kind] ?? request.kind; + const toolName = (request as Record).toolName as string | undefined; + const details = (request as Record).details as string | undefined; + + return ( + + ⚠ Permission Request + {taskId && Task: {taskId}} + + Kind: + {kindLabel} + + {toolName && ( + + Tool: + {toolName} + + )} + {details && ( + + Details: + {details} + + )} + + y + approve + n + deny + + + ); +} diff --git a/src/screens/execute.tsx b/src/screens/execute.tsx index 9d12dbb..4d01f51 100644 --- a/src/screens/execute.tsx +++ b/src/screens/execute.tsx @@ -3,8 +3,10 @@ import { Box, Text, useInput } from 'ink'; import type { Plan, Task } from '../models/plan.js'; import { executePlan } from '../services/executor.js'; import type { ExecutionOptions, ExecutionHandle, SessionEventWithTask } from '../services/executor.js'; +import type { PermissionRequest, PermissionRequestResult } from '../services/copilot.js'; import { savePlan, summarizePlan } from '../services/persistence.js'; import { computeBatches } from '../utils/dependency-graph.js'; +import PermissionPrompt from '../components/permission-prompt.js'; import Spinner from '../components/spinner.js'; import StatusBar from '../components/status-bar.js'; @@ -48,12 +50,31 @@ export default function ExecuteScreen({ const [summarized, setSummarized] = useState(''); const [sessionEvents, setSessionEvents] = useState([]); const [taskContexts, setTaskContexts] = useState>({}); + const [permissionLog, setPermissionLog] = useState>([]); + const [pendingPermission, setPendingPermission] = useState<{ + request: PermissionRequest; + taskId?: string; + resolve: (result: PermissionRequestResult) => void; + } | null>(null); const { batches } = computeBatches(plan.tasks); // Total display batches: init batch (index 0) + real batches const totalDisplayBatches = batches.length + 1; useInput((ch, key) => { + // Handle pending permission requests first + if (pendingPermission) { + if (ch === 'y') { + setPermissionLog((prev) => [...prev, { request: pendingPermission.request, taskId: pendingPermission.taskId, approved: true }]); + pendingPermission.resolve({ kind: 'approved' }); + setPendingPermission(null); + } else if (ch === 'n') { + setPermissionLog((prev) => [...prev, { request: pendingPermission.request, taskId: pendingPermission.taskId, approved: false }]); + pendingPermission.resolve({ kind: 'denied-interactively-by-user' }); + setPendingPermission(null); + } + return; + } if (key.escape && !executing) onBack(); if (ch === 'x' && !started) { setStarted(true); @@ -213,6 +234,11 @@ export default function ExecuteScreen({ })); } }, + onPermissionRequest: (request, _invocation) => { + return new Promise((resolve) => { + setPendingPermission({ request, taskId: undefined, resolve }); + }); + }, }, execOptions); execHandleRef.current = handle; @@ -431,6 +457,25 @@ export default function ExecuteScreen({ ); })()} + {/* Permission request prompt */} + {pendingPermission && ( + + )} + + {/* Permission decision log */} + {permissionLog.length > 0 && ( + + {permissionLog.slice(-3).map((entry, i) => ( + + + {entry.approved ? '✓' : '✗'} Permission {entry.approved ? 'approved' : 'denied'}: + + {entry.request.kind}{entry.taskId ? ` (${entry.taskId})` : ''} + + ))} + + )} + {/* Retry prompt when there are failures */} {started && !executing && failedCount > 0 && ( @@ -459,15 +504,17 @@ export default function ExecuteScreen({ 0 - ? '←→: switch batch ↑↓: select task r: retry task ⏳ executing...' - : executing - ? '←→: switch batch ↑↓: select task ⏳ executing...' - : started && failedCount > 0 - ? '←→: switch batch ↑↓: select task r: retry z: summarize esc: back' - : started - ? '←→: switch batch ↑↓: select task z: summarize esc: back' - : 'x: start esc: back' + pendingPermission + ? 'y: approve permission n: deny permission' + : executing && failedCount > 0 + ? '←→: switch batch ↑↓: select task r: retry task ⏳ executing...' + : executing + ? '←→: switch batch ↑↓: select task ⏳ executing...' + : started && failedCount > 0 + ? '←→: switch batch ↑↓: select task r: retry z: summarize esc: back' + : started + ? '←→: switch batch ↑↓: select task z: summarize esc: back' + : 'x: start esc: back' } /> diff --git a/src/services/copilot.ts b/src/services/copilot.ts index 36dceaf..17586d7 100644 --- a/src/services/copilot.ts +++ b/src/services/copilot.ts @@ -1,12 +1,12 @@ import { CopilotClient } from '@github/copilot-sdk'; import { readFile, writeFile, mkdir, readdir } from 'node:fs/promises'; -import type { SessionEvent } from '@github/copilot-sdk'; +import type { SessionEvent, PermissionRequest, PermissionRequestResult, PermissionHandler } from '@github/copilot-sdk'; import { join } from 'node:path'; import { existsSync } from 'node:fs'; import type { ChatMessage, SkillConfig } from '../models/plan.js'; -// Re-export SessionEvent for use in other modules -export type { SessionEvent }; +// Re-export types for use in other modules +export type { SessionEvent, PermissionRequest, PermissionRequestResult, PermissionHandler }; const SETTINGS_PATH = join(process.cwd(), '.planeteer', 'settings.json'); const SKILLS_DIR = join(process.cwd(), '.github', 'skills'); @@ -182,6 +182,7 @@ export interface StreamCallbacks { onDone: (fullText: string) => void; onError: (error: Error) => void; onSessionEvent?: (event: SessionEvent) => void; + onPermissionRequest?: PermissionHandler; } export async function sendPrompt( @@ -205,6 +206,7 @@ export async function sendPrompt( streaming: boolean; skillDirectories?: string[]; disabledSkills?: string[]; + onPermissionRequest?: PermissionHandler; } const sessionConfig: SessionConfigWithSkills = { @@ -219,6 +221,10 @@ export async function sendPrompt( if (skillOptions?.disabledSkills && skillOptions.disabledSkills.length > 0) { sessionConfig.disabledSkills = skillOptions.disabledSkills; } + + if (callbacks.onPermissionRequest) { + sessionConfig.onPermissionRequest = callbacks.onPermissionRequest; + } session = await copilot.createSession(sessionConfig); } catch (err) { @@ -283,12 +289,14 @@ export async function sendPromptSync( timeoutMs?: number; onDelta?: (delta: string, fullText: string) => void; onSessionEvent?: (event: SessionEvent) => void; + onPermissionRequest?: PermissionHandler; skillOptions?: SkillOptions; }, ): Promise { const idleTimeoutMs = options?.timeoutMs ?? 120_000; const onDelta = options?.onDelta; const onSessionEvent = options?.onSessionEvent; + const onPermissionRequest = options?.onPermissionRequest; const skillOptions = options?.skillOptions; return new Promise((resolve, reject) => { @@ -346,6 +354,7 @@ export async function sendPromptSync( } }, onSessionEvent, + onPermissionRequest, }, skillOptions); }); } diff --git a/src/services/executor.test.ts b/src/services/executor.test.ts index d154c82..fa38238 100644 --- a/src/services/executor.test.ts +++ b/src/services/executor.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi } from 'vitest'; import type { ExecutionCallbacks, SessionEventWithTask } from './executor.js'; -import type { SessionEvent } from './copilot.js'; +import type { SessionEvent, PermissionRequest, PermissionRequestResult, PermissionHandler } from './copilot.js'; describe('SessionEventWithTask type', () => { it('should correctly structure context change events with task ID', () => { @@ -114,3 +114,73 @@ describe('ExecutionCallbacks with session events', () => { }); }); }); + +describe('ExecutionCallbacks with permission handler', () => { + it('should define onPermissionRequest callback as optional', () => { + const callbacks: ExecutionCallbacks = { + onTaskStart: vi.fn(), + onTaskDelta: vi.fn(), + onTaskDone: vi.fn(), + onTaskFailed: vi.fn(), + onBatchComplete: vi.fn(), + onAllDone: vi.fn(), + // onPermissionRequest is optional + }; + + expect(callbacks.onPermissionRequest).toBeUndefined(); + }); + + it('should accept and call onPermissionRequest for approval', async () => { + const approveResult: PermissionRequestResult = { kind: 'approved' }; + const permissionHandler: PermissionHandler = vi.fn().mockResolvedValue(approveResult); + + const callbacks: ExecutionCallbacks = { + onTaskStart: vi.fn(), + onTaskDelta: vi.fn(), + onTaskDone: vi.fn(), + onTaskFailed: vi.fn(), + onBatchComplete: vi.fn(), + onAllDone: vi.fn(), + onPermissionRequest: permissionHandler, + }; + + expect(callbacks.onPermissionRequest).toBeDefined(); + + const request: PermissionRequest = { kind: 'shell', toolCallId: 'tc-1' }; + const invocation = { sessionId: 'sess-abc' }; + const result = await callbacks.onPermissionRequest?.(request, invocation); + + expect(permissionHandler).toHaveBeenCalledWith(request, invocation); + expect(result).toEqual(approveResult); + expect(result?.kind).toBe('approved'); + }); + + it('should accept and call onPermissionRequest for denial', async () => { + const denyResult: PermissionRequestResult = { kind: 'denied-interactively-by-user' }; + const permissionHandler: PermissionHandler = vi.fn().mockResolvedValue(denyResult); + + const callbacks: ExecutionCallbacks = { + onTaskStart: vi.fn(), + onTaskDelta: vi.fn(), + onTaskDone: vi.fn(), + onTaskFailed: vi.fn(), + onBatchComplete: vi.fn(), + onAllDone: vi.fn(), + onPermissionRequest: permissionHandler, + }; + + const request: PermissionRequest = { kind: 'write', toolCallId: 'tc-2' }; + const invocation = { sessionId: 'sess-def' }; + const result = await callbacks.onPermissionRequest?.(request, invocation); + + expect(result?.kind).toBe('denied-interactively-by-user'); + }); + + it('should handle all supported permission kinds', () => { + const kinds: PermissionRequest['kind'][] = ['shell', 'write', 'read', 'mcp', 'url']; + kinds.forEach((kind) => { + const request: PermissionRequest = { kind }; + expect(request.kind).toBe(kind); + }); + }); +}); diff --git a/src/services/executor.ts b/src/services/executor.ts index 09d6b6c..e3aa112 100644 --- a/src/services/executor.ts +++ b/src/services/executor.ts @@ -1,6 +1,6 @@ import type { Plan, Task } from '../models/plan.js'; import { sendPromptSync } from './copilot.js'; -import type { SessionEvent } from './copilot.js'; +import type { SessionEvent, PermissionHandler } from './copilot.js'; import { getReadyTasks } from '../utils/dependency-graph.js'; export interface SessionEventWithTask { @@ -16,6 +16,7 @@ export interface ExecutionCallbacks { onBatchComplete: (batchIndex: number) => void; onAllDone: (plan: Plan) => void; onSessionEvent?: (eventWithTask: SessionEventWithTask) => void; + onPermissionRequest?: PermissionHandler; } function buildTaskPrompt(task: Task, plan: Plan, codebaseContext?: string): string { @@ -122,6 +123,7 @@ export function executePlan( onSessionEvent: (event) => { callbacks.onSessionEvent?.({ taskId: task.id, event }); }, + onPermissionRequest: callbacks.onPermissionRequest, }); taskInPlan.status = 'done'; taskInPlan.agentResult = result; @@ -193,6 +195,7 @@ export function executePlan( onSessionEvent: (event) => { callbacks.onSessionEvent?.({ taskId: INIT_TASK_ID, event }); }, + onPermissionRequest: callbacks.onPermissionRequest, }); callbacks.onTaskDone(INIT_TASK_ID, initResult); } catch (err) {