mcp-copilot-cli 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +207 -0
- package/bin/mcp-copilot-cli.mjs +3 -0
- package/dist/src/app.d.ts +71 -0
- package/dist/src/app.js +303 -0
- package/dist/src/app.js.map +1 -0
- package/dist/src/cli/doctor.d.ts +2 -0
- package/dist/src/cli/doctor.js +99 -0
- package/dist/src/cli/doctor.js.map +1 -0
- package/dist/src/config/defaults.d.ts +3 -0
- package/dist/src/config/defaults.js +4 -0
- package/dist/src/config/defaults.js.map +1 -0
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.js +98 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/mcp/system-status.d.ts +19 -0
- package/dist/src/mcp/system-status.js +20 -0
- package/dist/src/mcp/system-status.js.map +1 -0
- package/dist/src/mcp/task-markdown.d.ts +3 -0
- package/dist/src/mcp/task-markdown.js +70 -0
- package/dist/src/mcp/task-markdown.js.map +1 -0
- package/dist/src/mcp/tool-banners.d.ts +4 -0
- package/dist/src/mcp/tool-banners.js +26 -0
- package/dist/src/mcp/tool-banners.js.map +1 -0
- package/dist/src/mcp/tool-definitions.d.ts +109 -0
- package/dist/src/mcp/tool-definitions.js +125 -0
- package/dist/src/mcp/tool-definitions.js.map +1 -0
- package/dist/src/services/copilot-runtime.d.ts +28 -0
- package/dist/src/services/copilot-runtime.js +194 -0
- package/dist/src/services/copilot-runtime.js.map +1 -0
- package/dist/src/services/output-log.d.ts +7 -0
- package/dist/src/services/output-log.js +59 -0
- package/dist/src/services/output-log.js.map +1 -0
- package/dist/src/services/profile-manager.d.ts +34 -0
- package/dist/src/services/profile-manager.js +113 -0
- package/dist/src/services/profile-manager.js.map +1 -0
- package/dist/src/services/question-registry.d.ts +29 -0
- package/dist/src/services/question-registry.js +115 -0
- package/dist/src/services/question-registry.js.map +1 -0
- package/dist/src/services/spawn-validation.d.ts +9 -0
- package/dist/src/services/spawn-validation.js +53 -0
- package/dist/src/services/spawn-validation.js.map +1 -0
- package/dist/src/services/task-manager.d.ts +34 -0
- package/dist/src/services/task-manager.js +107 -0
- package/dist/src/services/task-manager.js.map +1 -0
- package/dist/src/services/task-persistence.d.ts +20 -0
- package/dist/src/services/task-persistence.js +67 -0
- package/dist/src/services/task-persistence.js.map +1 -0
- package/dist/src/services/task-store.d.ts +12 -0
- package/dist/src/services/task-store.js +167 -0
- package/dist/src/services/task-store.js.map +1 -0
- package/dist/src/services/workspace-isolation.d.ts +13 -0
- package/dist/src/services/workspace-isolation.js +74 -0
- package/dist/src/services/workspace-isolation.js.map +1 -0
- package/dist/src/templates/agent-prompt.d.ts +6 -0
- package/dist/src/templates/agent-prompt.js +58 -0
- package/dist/src/templates/agent-prompt.js.map +1 -0
- package/dist/src/types/task.d.ts +79 -0
- package/dist/src/types/task.js +22 -0
- package/dist/src/types/task.js.map +1 -0
- package/package.json +63 -0
- package/src/app.ts +341 -0
- package/src/cli/doctor.ts +112 -0
- package/src/config/defaults.ts +3 -0
- package/src/index.ts +128 -0
- package/src/mcp/system-status.ts +41 -0
- package/src/mcp/task-markdown.ts +81 -0
- package/src/mcp/tool-banners.ts +32 -0
- package/src/mcp/tool-definitions.ts +151 -0
- package/src/services/copilot-runtime.ts +247 -0
- package/src/services/output-log.ts +68 -0
- package/src/services/profile-manager.ts +165 -0
- package/src/services/question-registry.ts +169 -0
- package/src/services/spawn-validation.ts +75 -0
- package/src/services/task-manager.ts +144 -0
- package/src/services/task-persistence.ts +100 -0
- package/src/services/task-store.ts +207 -0
- package/src/services/workspace-isolation.ts +100 -0
- package/src/templates/agent-prompt.ts +71 -0
- package/src/types/task.ts +95 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
4
|
+
|
|
5
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
6
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
7
|
+
import {
|
|
8
|
+
CancelTaskRequestSchema,
|
|
9
|
+
CallToolRequestSchema,
|
|
10
|
+
GetTaskPayloadRequestSchema,
|
|
11
|
+
GetTaskRequestSchema,
|
|
12
|
+
ListTasksRequestSchema,
|
|
13
|
+
ListResourcesRequestSchema,
|
|
14
|
+
ListToolsRequestSchema,
|
|
15
|
+
ReadResourceRequestSchema,
|
|
16
|
+
} from '@modelcontextprotocol/sdk/types.js';
|
|
17
|
+
|
|
18
|
+
import { CopilotMcpApp } from './app.js';
|
|
19
|
+
|
|
20
|
+
const require = createRequire(import.meta.url);
|
|
21
|
+
const pkg = require('../package.json') as { version: string };
|
|
22
|
+
|
|
23
|
+
const app = new CopilotMcpApp();
|
|
24
|
+
const server = new Server(
|
|
25
|
+
{ name: 'mcp-copilot-cli', version: pkg.version },
|
|
26
|
+
{
|
|
27
|
+
capabilities: {
|
|
28
|
+
tools: {},
|
|
29
|
+
resources: {},
|
|
30
|
+
tasks: {
|
|
31
|
+
list: {},
|
|
32
|
+
cancel: {},
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
39
|
+
tools: app.listTools().map((tool) => ({
|
|
40
|
+
name: tool.name,
|
|
41
|
+
description: tool.description,
|
|
42
|
+
inputSchema: tool.inputSchema,
|
|
43
|
+
})),
|
|
44
|
+
}));
|
|
45
|
+
|
|
46
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
47
|
+
const tools = app.listTools();
|
|
48
|
+
const tool = tools.find((candidate) => candidate.name === request.params.name);
|
|
49
|
+
if (!tool) {
|
|
50
|
+
return {
|
|
51
|
+
content: [{ type: 'text', text: `Unknown tool: ${request.params.name}` }],
|
|
52
|
+
isError: true as const,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
const args = tool.validate(request.params.arguments ?? {});
|
|
58
|
+
|
|
59
|
+
switch (tool.name) {
|
|
60
|
+
case 'spawn-agent':
|
|
61
|
+
return { content: [{ type: 'text', text: await app.spawnAgent(args as never) }] };
|
|
62
|
+
case 'message-agent':
|
|
63
|
+
return { content: [{ type: 'text', text: await app.messageAgent(args as never) }] };
|
|
64
|
+
case 'cancel-agent':
|
|
65
|
+
return { content: [{ type: 'text', text: await app.cancelAgent(args as never) }] };
|
|
66
|
+
case 'answer-agent':
|
|
67
|
+
return { content: [{ type: 'text', text: await app.answerAgent(args as never) }] };
|
|
68
|
+
default:
|
|
69
|
+
return {
|
|
70
|
+
content: [{ type: 'text', text: `Unhandled tool: ${tool.name}` }],
|
|
71
|
+
isError: true as const,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
} catch (error) {
|
|
75
|
+
return {
|
|
76
|
+
content: [{ type: 'text', text: error instanceof Error ? error.message : String(error) }],
|
|
77
|
+
isError: true as const,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
server.setRequestHandler(ListResourcesRequestSchema, async () => ({
|
|
83
|
+
resources: app.listResources(),
|
|
84
|
+
}));
|
|
85
|
+
|
|
86
|
+
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
|
|
87
|
+
const resource = await app.readResource(request.params.uri);
|
|
88
|
+
return {
|
|
89
|
+
contents: [
|
|
90
|
+
{
|
|
91
|
+
uri: request.params.uri,
|
|
92
|
+
mimeType: resource.mimeType,
|
|
93
|
+
text: resource.text,
|
|
94
|
+
},
|
|
95
|
+
],
|
|
96
|
+
};
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
server.setRequestHandler(ListTasksRequestSchema, async () => ({
|
|
100
|
+
tasks: app.listTaskSummaries(),
|
|
101
|
+
}));
|
|
102
|
+
|
|
103
|
+
server.setRequestHandler(GetTaskRequestSchema, async (request) => app.getTaskSummary(request.params.taskId));
|
|
104
|
+
|
|
105
|
+
server.setRequestHandler(GetTaskPayloadRequestSchema, async (request) => app.getTaskPayload(request.params.taskId));
|
|
106
|
+
|
|
107
|
+
server.setRequestHandler(CancelTaskRequestSchema, async (request) => app.cancelTaskById(request.params.taskId));
|
|
108
|
+
|
|
109
|
+
async function main(): Promise<void> {
|
|
110
|
+
await app.initialize(process.cwd());
|
|
111
|
+
|
|
112
|
+
const transport = new StdioServerTransport();
|
|
113
|
+
await server.connect(transport);
|
|
114
|
+
process.stdin.resume();
|
|
115
|
+
|
|
116
|
+
const shutdown = async () => {
|
|
117
|
+
await app.shutdown().catch(() => {});
|
|
118
|
+
process.exit(0);
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
process.on('SIGINT', () => void shutdown());
|
|
122
|
+
process.on('SIGTERM', () => void shutdown());
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
main().catch((error) => {
|
|
126
|
+
console.error(error);
|
|
127
|
+
process.exit(1);
|
|
128
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { CopilotProfile } from '../services/profile-manager.js';
|
|
2
|
+
import type { TaskRecord } from '../types/task.js';
|
|
3
|
+
import { TaskStatus } from '../types/task.js';
|
|
4
|
+
|
|
5
|
+
export interface SystemStatus {
|
|
6
|
+
tasks: {
|
|
7
|
+
total: number;
|
|
8
|
+
running: number;
|
|
9
|
+
waiting: number;
|
|
10
|
+
waitingAnswer: number;
|
|
11
|
+
terminal: number;
|
|
12
|
+
};
|
|
13
|
+
profiles: {
|
|
14
|
+
total: number;
|
|
15
|
+
coolingDown: number;
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function buildSystemStatus(input: {
|
|
20
|
+
tasks: TaskRecord[];
|
|
21
|
+
profiles: CopilotProfile[];
|
|
22
|
+
}): SystemStatus {
|
|
23
|
+
return {
|
|
24
|
+
tasks: {
|
|
25
|
+
total: input.tasks.length,
|
|
26
|
+
running: input.tasks.filter((task) => task.status === TaskStatus.RUNNING).length,
|
|
27
|
+
waiting: input.tasks.filter((task) => task.status === TaskStatus.WAITING).length,
|
|
28
|
+
waitingAnswer: input.tasks.filter((task) => task.status === TaskStatus.WAITING_ANSWER).length,
|
|
29
|
+
terminal: input.tasks.filter((task) =>
|
|
30
|
+
task.status === TaskStatus.COMPLETED ||
|
|
31
|
+
task.status === TaskStatus.FAILED ||
|
|
32
|
+
task.status === TaskStatus.CANCELLED ||
|
|
33
|
+
task.status === TaskStatus.TIMED_OUT,
|
|
34
|
+
).length,
|
|
35
|
+
},
|
|
36
|
+
profiles: {
|
|
37
|
+
total: input.profiles.length,
|
|
38
|
+
coolingDown: input.profiles.filter((profile) => profile.cooldownUntil !== undefined).length,
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { TaskStatus, type TaskRecord } from '../types/task.js';
|
|
2
|
+
|
|
3
|
+
function renderLogInstructions(task: TaskRecord): string[] {
|
|
4
|
+
if (!task.outputFilePath) {
|
|
5
|
+
return [];
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
return [
|
|
9
|
+
'## Read Logs',
|
|
10
|
+
'',
|
|
11
|
+
'```bash',
|
|
12
|
+
`cat -n ${task.outputFilePath}`,
|
|
13
|
+
'```',
|
|
14
|
+
'',
|
|
15
|
+
'Use `cat -n` to read with line numbers, then on subsequent reads use `tail -n +<N>` to skip already-read lines.',
|
|
16
|
+
'',
|
|
17
|
+
];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function renderTaskMarkdown(task: TaskRecord): string {
|
|
21
|
+
const lines: string[] = [
|
|
22
|
+
`# Task: ${task.id}`,
|
|
23
|
+
'',
|
|
24
|
+
'| Field | Value |',
|
|
25
|
+
'|---|---|',
|
|
26
|
+
`| **Status** | \`${task.status}\` |`,
|
|
27
|
+
`| **Model** | \`${task.model}\` |`,
|
|
28
|
+
`| **Agent Type** | \`${task.agentType}\` |`,
|
|
29
|
+
`| **Started** | ${task.startTime} |`,
|
|
30
|
+
'',
|
|
31
|
+
...renderLogInstructions(task),
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
if (
|
|
35
|
+
task.status === TaskStatus.RUNNING ||
|
|
36
|
+
task.status === TaskStatus.PENDING ||
|
|
37
|
+
task.status === TaskStatus.WAITING
|
|
38
|
+
) {
|
|
39
|
+
lines.push('## What to do next', '');
|
|
40
|
+
lines.push('The agent is still working. If you need to wait, run `sleep 30` and then read this resource again.');
|
|
41
|
+
if (task.outputFilePath) {
|
|
42
|
+
lines.push(`For a quick progress check without reading the full resource, run \`wc -l ${task.outputFilePath}\` — a growing line count means the agent is still working.`);
|
|
43
|
+
}
|
|
44
|
+
lines.push('If the agent is still running after your first check, wait longer before checking again: `sleep 60`, then `sleep 90`, `sleep 120`, `sleep 150`, up to `sleep 180` max.');
|
|
45
|
+
lines.push('');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (task.pendingQuestion) {
|
|
49
|
+
lines.push('## ACTION REQUIRED', '');
|
|
50
|
+
lines.push(`**Question:** ${task.pendingQuestion.question}`);
|
|
51
|
+
lines.push('');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (task.output.length > 0) {
|
|
55
|
+
lines.push('## Recent Output', '', '```');
|
|
56
|
+
lines.push(...task.output.slice(-50));
|
|
57
|
+
lines.push('```');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return lines.join('\n');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function renderTaskListMarkdown(tasks: TaskRecord[]): string {
|
|
64
|
+
const lines = [
|
|
65
|
+
`# Tasks (${tasks.length} total)`,
|
|
66
|
+
'',
|
|
67
|
+
'| ID | Status | Prompt |',
|
|
68
|
+
'|---|---|---|',
|
|
69
|
+
...tasks.map((task) => `| ${task.id} | ${task.status} | ${task.prompt.slice(0, 40)} |`),
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
const pending = tasks.filter((task) => task.status === TaskStatus.WAITING_ANSWER);
|
|
73
|
+
if (pending.length > 0) {
|
|
74
|
+
lines.push('', `## Pending Questions (${pending.length})`, '');
|
|
75
|
+
for (const task of pending) {
|
|
76
|
+
lines.push(`### ${task.id}`, '', `**Q:** ${task.pendingQuestion?.question ?? 'Pending answer required'}`, '');
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return lines.join('\n');
|
|
81
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { QuestionRegistry } from '../services/question-registry.js';
|
|
2
|
+
import type { TaskManager } from '../services/task-manager.js';
|
|
3
|
+
import { TaskStatus } from '../types/task.js';
|
|
4
|
+
|
|
5
|
+
export function buildMessageBanner(taskManager: TaskManager): string {
|
|
6
|
+
const tasks = taskManager.getAllTasks();
|
|
7
|
+
if (tasks.length === 0) {
|
|
8
|
+
return '';
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const running = tasks.filter((task) => task.status === TaskStatus.RUNNING).length;
|
|
12
|
+
const waitingAnswer = tasks.filter((task) => task.status === TaskStatus.WAITING_ANSWER).length;
|
|
13
|
+
const recent = tasks.slice(-3).map((task) => `- ${task.id} [${task.status}]`).join('\n');
|
|
14
|
+
|
|
15
|
+
return ['---', `AGENT STATUS: ${running} running | ${waitingAnswer} needs answer`, recent, 'Read task:///all for full details.']
|
|
16
|
+
.filter(Boolean)
|
|
17
|
+
.join('\n');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function buildAnswerBanner(questionRegistry: QuestionRegistry): string {
|
|
21
|
+
const pending = questionRegistry.getAllPendingQuestions();
|
|
22
|
+
if (pending.size === 0) {
|
|
23
|
+
return '';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return [
|
|
27
|
+
'---',
|
|
28
|
+
`ACTION REQUIRED — ${pending.size} task${pending.size > 1 ? 's' : ''} waiting for your answer:`,
|
|
29
|
+
...[...pending.entries()].map(([taskId, question]) => `- ${taskId}: "${question.question}"`),
|
|
30
|
+
'Use answer-agent { "task_id": "<id>", "answer": "<choice>" }',
|
|
31
|
+
].join('\n');
|
|
32
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
const contextFileSchema = z.object({
|
|
4
|
+
path: z.string().min(1),
|
|
5
|
+
description: z.string().optional(),
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
const spawnAgentSchema = z.object({
|
|
9
|
+
prompt: z.string().min(1).max(100_000),
|
|
10
|
+
agent_type: z.enum(['coder', 'planner', 'researcher', 'tester', 'general']),
|
|
11
|
+
context_files: z.array(contextFileSchema).max(20).optional(),
|
|
12
|
+
model: z.string().optional(),
|
|
13
|
+
cwd: z.string().optional(),
|
|
14
|
+
timeout: z.number().int().positive().max(3_600_000).optional(),
|
|
15
|
+
depends_on: z.array(z.string().min(1)).max(50).optional(),
|
|
16
|
+
labels: z.array(z.string().min(1).max(50)).max(10).optional(),
|
|
17
|
+
isolation_mode: z.enum(['shared', 'isolated']).optional(),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const messageAgentSchema = z.object({
|
|
21
|
+
task_id: z.string().min(1),
|
|
22
|
+
message: z.string().optional(),
|
|
23
|
+
timeout: z.number().int().positive().max(3_600_000).optional(),
|
|
24
|
+
cwd: z.string().optional(),
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const cancelAgentSchema = z.object({
|
|
28
|
+
task_id: z.union([z.string().min(1), z.array(z.string().min(1)).min(1).max(50)]),
|
|
29
|
+
clear: z.boolean().optional(),
|
|
30
|
+
confirm: z.boolean().optional(),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const answerAgentSchema = z.object({
|
|
34
|
+
task_id: z.string().min(1),
|
|
35
|
+
answer: z.string().min(1).optional(),
|
|
36
|
+
answers: z.record(z.string(), z.string().min(1)).optional(),
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
export type SpawnAgentInput = z.infer<typeof spawnAgentSchema>;
|
|
40
|
+
export type MessageAgentInput = z.infer<typeof messageAgentSchema>;
|
|
41
|
+
export type CancelAgentInput = z.infer<typeof cancelAgentSchema>;
|
|
42
|
+
export type AnswerAgentInput = z.infer<typeof answerAgentSchema>;
|
|
43
|
+
|
|
44
|
+
export interface ToolDefinition {
|
|
45
|
+
name: string;
|
|
46
|
+
description: string;
|
|
47
|
+
inputSchema: {
|
|
48
|
+
type: 'object';
|
|
49
|
+
properties: Record<string, unknown>;
|
|
50
|
+
required?: string[];
|
|
51
|
+
};
|
|
52
|
+
validate: (input: unknown) => unknown;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface CreateToolDefinitionsInput {
|
|
56
|
+
messageBanner: string;
|
|
57
|
+
answerBanner: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function createToolDefinitions(input: CreateToolDefinitionsInput): ToolDefinition[] {
|
|
61
|
+
return [
|
|
62
|
+
{
|
|
63
|
+
name: 'spawn-agent',
|
|
64
|
+
description: [
|
|
65
|
+
'Launch a Copilot-backed task in the background.',
|
|
66
|
+
'Use `task:///all` to monitor status and `task:///{id}` for live detail.',
|
|
67
|
+
].join(' '),
|
|
68
|
+
inputSchema: {
|
|
69
|
+
type: 'object',
|
|
70
|
+
properties: {
|
|
71
|
+
prompt: { type: 'string', minLength: 1, maxLength: 100000 },
|
|
72
|
+
agent_type: { type: 'string', enum: ['coder', 'planner', 'researcher', 'tester', 'general'] },
|
|
73
|
+
context_files: {
|
|
74
|
+
type: 'array',
|
|
75
|
+
items: {
|
|
76
|
+
type: 'object',
|
|
77
|
+
properties: {
|
|
78
|
+
path: { type: 'string', minLength: 1 },
|
|
79
|
+
description: { type: 'string' },
|
|
80
|
+
},
|
|
81
|
+
required: ['path'],
|
|
82
|
+
},
|
|
83
|
+
maxItems: 20,
|
|
84
|
+
},
|
|
85
|
+
model: { type: 'string' },
|
|
86
|
+
cwd: { type: 'string' },
|
|
87
|
+
timeout: { type: 'integer', minimum: 1, maximum: 3600000 },
|
|
88
|
+
depends_on: { type: 'array', items: { type: 'string', minLength: 1 }, maxItems: 50 },
|
|
89
|
+
labels: { type: 'array', items: { type: 'string', minLength: 1, maxLength: 50 }, maxItems: 10 },
|
|
90
|
+
isolation_mode: { type: 'string', enum: ['shared', 'isolated'] },
|
|
91
|
+
},
|
|
92
|
+
required: ['prompt', 'agent_type'],
|
|
93
|
+
},
|
|
94
|
+
validate: (payload) => spawnAgentSchema.parse(payload),
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
name: 'message-agent',
|
|
98
|
+
description: [
|
|
99
|
+
'Resume a terminal task with a follow-up message.',
|
|
100
|
+
input.messageBanner,
|
|
101
|
+
].filter(Boolean).join('\n\n'),
|
|
102
|
+
inputSchema: {
|
|
103
|
+
type: 'object',
|
|
104
|
+
properties: {
|
|
105
|
+
task_id: { type: 'string', minLength: 1 },
|
|
106
|
+
message: { type: 'string' },
|
|
107
|
+
timeout: { type: 'integer', minimum: 1, maximum: 3600000 },
|
|
108
|
+
cwd: { type: 'string' },
|
|
109
|
+
},
|
|
110
|
+
required: ['task_id'],
|
|
111
|
+
},
|
|
112
|
+
validate: (payload) => messageAgentSchema.parse(payload),
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
name: 'cancel-agent',
|
|
116
|
+
description: 'Cancel one task, many tasks, or clear all tracked tasks.',
|
|
117
|
+
inputSchema: {
|
|
118
|
+
type: 'object',
|
|
119
|
+
properties: {
|
|
120
|
+
task_id: {
|
|
121
|
+
type: ['string', 'array'],
|
|
122
|
+
items: { type: 'string', minLength: 1 },
|
|
123
|
+
minItems: 1,
|
|
124
|
+
maxItems: 50,
|
|
125
|
+
},
|
|
126
|
+
clear: { type: 'boolean' },
|
|
127
|
+
confirm: { type: 'boolean' },
|
|
128
|
+
},
|
|
129
|
+
required: ['task_id'],
|
|
130
|
+
},
|
|
131
|
+
validate: (payload) => cancelAgentSchema.parse(payload),
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
name: 'answer-agent',
|
|
135
|
+
description: [
|
|
136
|
+
'Answer a pending agent question and resume execution.',
|
|
137
|
+
input.answerBanner,
|
|
138
|
+
].filter(Boolean).join('\n\n'),
|
|
139
|
+
inputSchema: {
|
|
140
|
+
type: 'object',
|
|
141
|
+
properties: {
|
|
142
|
+
task_id: { type: 'string', minLength: 1 },
|
|
143
|
+
answer: { type: 'string', minLength: 1 },
|
|
144
|
+
answers: { type: 'object', additionalProperties: { type: 'string', minLength: 1 } },
|
|
145
|
+
},
|
|
146
|
+
required: ['task_id'],
|
|
147
|
+
},
|
|
148
|
+
validate: (payload) => answerAgentSchema.parse(payload),
|
|
149
|
+
},
|
|
150
|
+
];
|
|
151
|
+
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { accessSync, constants } from 'node:fs';
|
|
2
|
+
import { delimiter, join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
CopilotClient,
|
|
6
|
+
approveAll,
|
|
7
|
+
type CopilotClientOptions,
|
|
8
|
+
type CopilotSession,
|
|
9
|
+
} from '@github/copilot-sdk';
|
|
10
|
+
|
|
11
|
+
import { DEFAULT_TIMEOUT_MS } from '../config/defaults.js';
|
|
12
|
+
import { buildAgentPrompt } from '../templates/agent-prompt.js';
|
|
13
|
+
import type { TaskRecord } from '../types/task.js';
|
|
14
|
+
import { TaskStatus } from '../types/task.js';
|
|
15
|
+
import type { CopilotProfile } from './profile-manager.js';
|
|
16
|
+
import type { ProfileManager } from './profile-manager.js';
|
|
17
|
+
import type { QuestionRegistry } from './question-registry.js';
|
|
18
|
+
import type { TaskManager } from './task-manager.js';
|
|
19
|
+
|
|
20
|
+
export interface BuildCopilotClientOptionsInput {
|
|
21
|
+
cwd: string;
|
|
22
|
+
profile: CopilotProfile;
|
|
23
|
+
cliPath?: string | undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function canExecute(path: string): boolean {
|
|
27
|
+
try {
|
|
28
|
+
accessSync(path, constants.X_OK);
|
|
29
|
+
return true;
|
|
30
|
+
} catch {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function resolveCopilotCliPath(explicitPath?: string): string {
|
|
36
|
+
if (explicitPath && canExecute(explicitPath)) {
|
|
37
|
+
return explicitPath;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const configuredPath = process.env.COPILOT_CLI_PATH;
|
|
41
|
+
if (configuredPath && canExecute(configuredPath)) {
|
|
42
|
+
return configuredPath;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
for (const segment of (process.env.PATH ?? '').split(delimiter)) {
|
|
46
|
+
if (!segment) {
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const candidate = join(segment, 'copilot');
|
|
51
|
+
if (canExecute(candidate)) {
|
|
52
|
+
return candidate;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return explicitPath ?? configuredPath ?? 'copilot';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function buildCopilotClientOptions(
|
|
60
|
+
input: BuildCopilotClientOptionsInput,
|
|
61
|
+
): CopilotClientOptions {
|
|
62
|
+
return {
|
|
63
|
+
cliPath: resolveCopilotCliPath(input.cliPath),
|
|
64
|
+
cliArgs: ['--config-dir', input.profile.configDir],
|
|
65
|
+
cwd: input.cwd,
|
|
66
|
+
useLoggedInUser: true,
|
|
67
|
+
useStdio: false,
|
|
68
|
+
autoStart: true,
|
|
69
|
+
autoRestart: false,
|
|
70
|
+
logLevel: 'error',
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function isRateLimitError(error: unknown): boolean {
|
|
75
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
76
|
+
const normalized = message.toLowerCase();
|
|
77
|
+
return (
|
|
78
|
+
normalized.includes('rate limit') ||
|
|
79
|
+
normalized.includes('too many requests') ||
|
|
80
|
+
normalized.includes('429') ||
|
|
81
|
+
normalized.includes('quota')
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export class CopilotRuntime {
|
|
86
|
+
private readonly clients = new Map<string, CopilotClient>();
|
|
87
|
+
private readonly taskManager: TaskManager;
|
|
88
|
+
private readonly profileManager: ProfileManager;
|
|
89
|
+
private readonly questionRegistry: QuestionRegistry;
|
|
90
|
+
|
|
91
|
+
constructor(input: {
|
|
92
|
+
taskManager: TaskManager;
|
|
93
|
+
profileManager: ProfileManager;
|
|
94
|
+
questionRegistry: QuestionRegistry;
|
|
95
|
+
}) {
|
|
96
|
+
this.taskManager = input.taskManager;
|
|
97
|
+
this.profileManager = input.profileManager;
|
|
98
|
+
this.questionRegistry = input.questionRegistry;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async runTask(task: TaskRecord): Promise<void> {
|
|
102
|
+
try {
|
|
103
|
+
const profile = this.resolveProfile(task);
|
|
104
|
+
const client = await this.getClient(task.baseCwd ?? task.cwd, profile);
|
|
105
|
+
const prompt = await buildAgentPrompt({
|
|
106
|
+
agentType: task.agentType,
|
|
107
|
+
prompt: task.prompt,
|
|
108
|
+
contextFiles: task.contextFiles,
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const session = task.sessionId
|
|
112
|
+
? await client.resumeSession(task.sessionId, {
|
|
113
|
+
onPermissionRequest: approveAll,
|
|
114
|
+
onUserInputRequest: (request, invocation) =>
|
|
115
|
+
this.questionRegistry.register({
|
|
116
|
+
taskId: task.id,
|
|
117
|
+
sessionId: invocation.sessionId,
|
|
118
|
+
question: request.question,
|
|
119
|
+
choices: request.choices,
|
|
120
|
+
allowFreeform: request.allowFreeform,
|
|
121
|
+
}),
|
|
122
|
+
streaming: true,
|
|
123
|
+
})
|
|
124
|
+
: await client.createSession({
|
|
125
|
+
model: task.model,
|
|
126
|
+
onPermissionRequest: approveAll,
|
|
127
|
+
onUserInputRequest: (request, invocation) =>
|
|
128
|
+
this.questionRegistry.register({
|
|
129
|
+
taskId: task.id,
|
|
130
|
+
sessionId: invocation.sessionId,
|
|
131
|
+
question: request.question,
|
|
132
|
+
choices: request.choices,
|
|
133
|
+
allowFreeform: request.allowFreeform,
|
|
134
|
+
}),
|
|
135
|
+
streaming: true,
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
await this.taskManager.registerExecution(task.id, {
|
|
139
|
+
abort: async () => {
|
|
140
|
+
await session.abort().catch(() => {});
|
|
141
|
+
},
|
|
142
|
+
cleanup: async () => {
|
|
143
|
+
await session.disconnect().catch(() => {});
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
await this.taskManager.updateTask(task.id, {
|
|
148
|
+
status: TaskStatus.RUNNING,
|
|
149
|
+
sessionId: session.sessionId,
|
|
150
|
+
profileId: profile.id,
|
|
151
|
+
profileConfigDir: profile.configDir,
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
this.bindSessionEvents(task.id, session);
|
|
155
|
+
|
|
156
|
+
await this.taskManager.appendOutput(task.id, `[session] profile: ${profile.id} (${profile.configDir})`);
|
|
157
|
+
await session.sendAndWait(
|
|
158
|
+
{ prompt },
|
|
159
|
+
task.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
160
|
+
);
|
|
161
|
+
await this.taskManager.updateTask(task.id, {
|
|
162
|
+
status: TaskStatus.COMPLETED,
|
|
163
|
+
endTime: new Date().toISOString(),
|
|
164
|
+
});
|
|
165
|
+
} catch (error) {
|
|
166
|
+
if (isRateLimitError(error)) {
|
|
167
|
+
this.profileManager.markFailure('rate_limit');
|
|
168
|
+
await this.taskManager.updateTask(task.id, {
|
|
169
|
+
status: TaskStatus.RATE_LIMITED,
|
|
170
|
+
error: error instanceof Error ? error.message : String(error),
|
|
171
|
+
endTime: new Date().toISOString(),
|
|
172
|
+
});
|
|
173
|
+
} else {
|
|
174
|
+
await this.taskManager.updateTask(task.id, {
|
|
175
|
+
status: TaskStatus.FAILED,
|
|
176
|
+
error: error instanceof Error ? error.message : String(error),
|
|
177
|
+
endTime: new Date().toISOString(),
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
} finally {
|
|
181
|
+
await this.taskManager.clearExecution(task.id);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async shutdown(): Promise<void> {
|
|
186
|
+
await Promise.all(
|
|
187
|
+
[...this.clients.values()].map(async (client) => {
|
|
188
|
+
await client.stop().catch(async () => {
|
|
189
|
+
await client.forceStop().catch(() => {});
|
|
190
|
+
});
|
|
191
|
+
}),
|
|
192
|
+
);
|
|
193
|
+
this.clients.clear();
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
private bindSessionEvents(taskId: string, session: CopilotSession): void {
|
|
197
|
+
session.on('assistant.message', (event) => {
|
|
198
|
+
void this.taskManager.appendOutput(taskId, event.data.content);
|
|
199
|
+
});
|
|
200
|
+
session.on('tool.execution_start', (event) => {
|
|
201
|
+
void this.taskManager.appendOutput(taskId, `[tool] start: ${event.data.toolName}`);
|
|
202
|
+
});
|
|
203
|
+
session.on('tool.execution_complete', (event) => {
|
|
204
|
+
const label = 'toolName' in event.data ? event.data.toolName : event.data.toolCallId;
|
|
205
|
+
void this.taskManager.appendOutput(taskId, `[tool] complete: ${label} success=${String(event.data.success)}`);
|
|
206
|
+
});
|
|
207
|
+
session.on('session.error', (event) => {
|
|
208
|
+
void this.taskManager.appendOutput(taskId, `[error] ${event.data.message}`);
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
private async getClient(cwd: string, profile: CopilotProfile): Promise<CopilotClient> {
|
|
213
|
+
const key = `${cwd}:${profile.configDir}`;
|
|
214
|
+
const existing = this.clients.get(key);
|
|
215
|
+
if (existing) {
|
|
216
|
+
return existing;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const client = new CopilotClient(buildCopilotClientOptions({ cwd, profile }));
|
|
220
|
+
await client.start();
|
|
221
|
+
const auth = await client.getAuthStatus();
|
|
222
|
+
if (!auth.isAuthenticated) {
|
|
223
|
+
throw new Error(
|
|
224
|
+
`Copilot profile "${profile.configDir}" is not authenticated. Run \`copilot login --config-dir ${profile.configDir}\`.`,
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
this.clients.set(key, client);
|
|
229
|
+
return client;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
private resolveProfile(task: TaskRecord): CopilotProfile {
|
|
233
|
+
if (task.profileConfigDir && task.profileId) {
|
|
234
|
+
const match = this.profileManager.getProfiles().find((profile) => profile.id === task.profileId);
|
|
235
|
+
if (match) {
|
|
236
|
+
return match;
|
|
237
|
+
}
|
|
238
|
+
return {
|
|
239
|
+
id: task.profileId,
|
|
240
|
+
configDir: task.profileConfigDir,
|
|
241
|
+
failureCount: 0,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return this.profileManager.getCurrentProfile();
|
|
246
|
+
}
|
|
247
|
+
}
|