Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions src/components/permission-prompt.tsx
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
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<string, unknown>).toolName as string | undefined;
const details = (request as Record<string, unknown>).details as string | undefined;

return (
<Box
flexDirection="column"
borderStyle="round"
borderColor="yellow"
paddingX={1}
marginBottom={1}
>
<Text color="yellow" bold>⚠ Permission Request</Text>
{taskId && <Text color="gray">Task: {taskId}</Text>}
<Box marginTop={1}>
<Text color="white">Kind: </Text>
<Text color="yellow">{kindLabel}</Text>
</Box>
{toolName && (
<Box>
<Text color="white">Tool: </Text>
<Text color="cyan">{toolName}</Text>
</Box>
)}
{details && (
<Box>
<Text color="white">Details: </Text>
<Text color="gray">{details}</Text>
</Box>
)}
<Box marginTop={1}>
<Text color="green" bold>y</Text>
<Text color="gray"> approve </Text>
<Text color="red" bold>n</Text>
<Text color="gray"> deny</Text>
</Box>
</Box>
);
}
65 changes: 56 additions & 9 deletions src/screens/execute.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -48,12 +50,31 @@ export default function ExecuteScreen({
const [summarized, setSummarized] = useState('');
const [sessionEvents, setSessionEvents] = useState<SessionEventWithTask[]>([]);
const [taskContexts, setTaskContexts] = useState<Record<string, { cwd?: string; repository?: string; branch?: string }>>({});
const [permissionLog, setPermissionLog] = useState<Array<{ request: PermissionRequest; taskId?: string; approved: boolean }>>([]);
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);
Expand Down Expand Up @@ -213,6 +234,11 @@ export default function ExecuteScreen({
}));
}
},
onPermissionRequest: (request, _invocation) => {
return new Promise<PermissionRequestResult>((resolve) => {
setPendingPermission({ request, taskId: undefined, resolve });
});
},
}, execOptions);

execHandleRef.current = handle;
Expand Down Expand Up @@ -431,6 +457,25 @@ export default function ExecuteScreen({
);
})()}

{/* Permission request prompt */}
{pendingPermission && (
<PermissionPrompt request={pendingPermission.request} taskId={pendingPermission.taskId} />
)}

{/* Permission decision log */}
{permissionLog.length > 0 && (
<Box flexDirection="column" marginBottom={1}>
{permissionLog.slice(-3).map((entry, i) => (
<Box key={i}>
<Text color={entry.approved ? 'green' : 'red'}>
{entry.approved ? '✓' : '✗'} Permission {entry.approved ? 'approved' : 'denied'}:
</Text>
<Text color="gray"> {entry.request.kind}{entry.taskId ? ` (${entry.taskId})` : ''}</Text>
</Box>
))}
</Box>
)}

{/* Retry prompt when there are failures */}
{started && !executing && failedCount > 0 && (
<Box marginBottom={1}>
Expand Down Expand Up @@ -459,15 +504,17 @@ export default function ExecuteScreen({
<StatusBar
screen="Execute"
hint={
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'
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'
}
/>
</Box>
Expand Down
15 changes: 12 additions & 3 deletions src/services/copilot.ts
Original file line number Diff line number Diff line change
@@ -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');
Expand Down Expand Up @@ -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(
Expand All @@ -205,6 +206,7 @@ export async function sendPrompt(
streaming: boolean;
skillDirectories?: string[];
disabledSkills?: string[];
onPermissionRequest?: PermissionHandler;
}

const sessionConfig: SessionConfigWithSkills = {
Expand All @@ -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) {
Expand Down Expand Up @@ -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<string> {
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) => {
Expand Down Expand Up @@ -346,6 +354,7 @@ export async function sendPromptSync(
}
},
onSessionEvent,
onPermissionRequest,
}, skillOptions);
});
}
72 changes: 71 additions & 1 deletion src/services/executor.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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);
});
});
});
5 changes: 4 additions & 1 deletion src/services/executor.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -122,6 +123,7 @@ export function executePlan(
onSessionEvent: (event) => {
callbacks.onSessionEvent?.({ taskId: task.id, event });
},
onPermissionRequest: callbacks.onPermissionRequest,
});
taskInPlan.status = 'done';
taskInPlan.agentResult = result;
Expand Down Expand Up @@ -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) {
Expand Down