mcp-copilot-worker 1.0.1

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.
Files changed (87) hide show
  1. package/README.md +209 -0
  2. package/bin/mcp-copilot-worker.mjs +3 -0
  3. package/dist/src/app.d.ts +71 -0
  4. package/dist/src/app.js +306 -0
  5. package/dist/src/app.js.map +1 -0
  6. package/dist/src/cli/doctor.d.ts +2 -0
  7. package/dist/src/cli/doctor.js +99 -0
  8. package/dist/src/cli/doctor.js.map +1 -0
  9. package/dist/src/config/defaults.d.ts +3 -0
  10. package/dist/src/config/defaults.js +4 -0
  11. package/dist/src/config/defaults.js.map +1 -0
  12. package/dist/src/index.d.ts +2 -0
  13. package/dist/src/index.js +98 -0
  14. package/dist/src/index.js.map +1 -0
  15. package/dist/src/mcp/system-status.d.ts +19 -0
  16. package/dist/src/mcp/system-status.js +20 -0
  17. package/dist/src/mcp/system-status.js.map +1 -0
  18. package/dist/src/mcp/task-markdown.d.ts +3 -0
  19. package/dist/src/mcp/task-markdown.js +70 -0
  20. package/dist/src/mcp/task-markdown.js.map +1 -0
  21. package/dist/src/mcp/tool-banners.d.ts +4 -0
  22. package/dist/src/mcp/tool-banners.js +26 -0
  23. package/dist/src/mcp/tool-banners.js.map +1 -0
  24. package/dist/src/mcp/tool-definitions.d.ts +111 -0
  25. package/dist/src/mcp/tool-definitions.js +134 -0
  26. package/dist/src/mcp/tool-definitions.js.map +1 -0
  27. package/dist/src/services/copilot-runtime.d.ts +35 -0
  28. package/dist/src/services/copilot-runtime.js +237 -0
  29. package/dist/src/services/copilot-runtime.js.map +1 -0
  30. package/dist/src/services/fleet-mode.d.ts +15 -0
  31. package/dist/src/services/fleet-mode.js +26 -0
  32. package/dist/src/services/fleet-mode.js.map +1 -0
  33. package/dist/src/services/model-catalog.d.ts +16 -0
  34. package/dist/src/services/model-catalog.js +95 -0
  35. package/dist/src/services/model-catalog.js.map +1 -0
  36. package/dist/src/services/output-log.d.ts +7 -0
  37. package/dist/src/services/output-log.js +59 -0
  38. package/dist/src/services/output-log.js.map +1 -0
  39. package/dist/src/services/profile-manager.d.ts +34 -0
  40. package/dist/src/services/profile-manager.js +113 -0
  41. package/dist/src/services/profile-manager.js.map +1 -0
  42. package/dist/src/services/question-registry.d.ts +29 -0
  43. package/dist/src/services/question-registry.js +115 -0
  44. package/dist/src/services/question-registry.js.map +1 -0
  45. package/dist/src/services/spawn-validation.d.ts +9 -0
  46. package/dist/src/services/spawn-validation.js +53 -0
  47. package/dist/src/services/spawn-validation.js.map +1 -0
  48. package/dist/src/services/task-manager.d.ts +34 -0
  49. package/dist/src/services/task-manager.js +107 -0
  50. package/dist/src/services/task-manager.js.map +1 -0
  51. package/dist/src/services/task-persistence.d.ts +20 -0
  52. package/dist/src/services/task-persistence.js +67 -0
  53. package/dist/src/services/task-persistence.js.map +1 -0
  54. package/dist/src/services/task-store.d.ts +12 -0
  55. package/dist/src/services/task-store.js +167 -0
  56. package/dist/src/services/task-store.js.map +1 -0
  57. package/dist/src/services/workspace-isolation.d.ts +13 -0
  58. package/dist/src/services/workspace-isolation.js +74 -0
  59. package/dist/src/services/workspace-isolation.js.map +1 -0
  60. package/dist/src/templates/agent-prompt.d.ts +6 -0
  61. package/dist/src/templates/agent-prompt.js +58 -0
  62. package/dist/src/templates/agent-prompt.js.map +1 -0
  63. package/dist/src/types/task.d.ts +79 -0
  64. package/dist/src/types/task.js +22 -0
  65. package/dist/src/types/task.js.map +1 -0
  66. package/package.json +63 -0
  67. package/src/app.ts +344 -0
  68. package/src/cli/doctor.ts +112 -0
  69. package/src/config/defaults.ts +3 -0
  70. package/src/index.ts +128 -0
  71. package/src/mcp/system-status.ts +41 -0
  72. package/src/mcp/task-markdown.ts +81 -0
  73. package/src/mcp/tool-banners.ts +32 -0
  74. package/src/mcp/tool-definitions.ts +163 -0
  75. package/src/services/copilot-runtime.ts +305 -0
  76. package/src/services/fleet-mode.ts +39 -0
  77. package/src/services/model-catalog.ts +136 -0
  78. package/src/services/output-log.ts +68 -0
  79. package/src/services/profile-manager.ts +165 -0
  80. package/src/services/question-registry.ts +169 -0
  81. package/src/services/spawn-validation.ts +75 -0
  82. package/src/services/task-manager.ts +144 -0
  83. package/src/services/task-persistence.ts +100 -0
  84. package/src/services/task-store.ts +207 -0
  85. package/src/services/workspace-isolation.ts +100 -0
  86. package/src/templates/agent-prompt.ts +71 -0
  87. package/src/types/task.ts +95 -0
@@ -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,163 @@
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
+ modelSuggestions?: string[] | undefined;
59
+ modelDescription?: string | undefined;
60
+ }
61
+
62
+ export function createToolDefinitions(input: CreateToolDefinitionsInput): ToolDefinition[] {
63
+ const modelProperty = {
64
+ type: 'string',
65
+ ...(input.modelSuggestions && input.modelSuggestions.length > 0
66
+ ? { enum: input.modelSuggestions }
67
+ : {}),
68
+ ...(input.modelDescription
69
+ ? { description: input.modelDescription }
70
+ : {}),
71
+ };
72
+
73
+ return [
74
+ {
75
+ name: 'spawn-agent',
76
+ description: [
77
+ 'Launch a Copilot-backed task in the background.',
78
+ 'Use `task:///all` to monitor status and `task:///{id}` for live detail.',
79
+ ].join(' '),
80
+ inputSchema: {
81
+ type: 'object',
82
+ properties: {
83
+ prompt: { type: 'string', minLength: 1, maxLength: 100000 },
84
+ agent_type: { type: 'string', enum: ['coder', 'planner', 'researcher', 'tester', 'general'] },
85
+ context_files: {
86
+ type: 'array',
87
+ items: {
88
+ type: 'object',
89
+ properties: {
90
+ path: { type: 'string', minLength: 1 },
91
+ description: { type: 'string' },
92
+ },
93
+ required: ['path'],
94
+ },
95
+ maxItems: 20,
96
+ },
97
+ model: modelProperty,
98
+ cwd: { type: 'string' },
99
+ timeout: { type: 'integer', minimum: 1, maximum: 3600000 },
100
+ depends_on: { type: 'array', items: { type: 'string', minLength: 1 }, maxItems: 50 },
101
+ labels: { type: 'array', items: { type: 'string', minLength: 1, maxLength: 50 }, maxItems: 10 },
102
+ isolation_mode: { type: 'string', enum: ['shared', 'isolated'] },
103
+ },
104
+ required: ['prompt', 'agent_type'],
105
+ },
106
+ validate: (payload) => spawnAgentSchema.parse(payload),
107
+ },
108
+ {
109
+ name: 'message-agent',
110
+ description: [
111
+ 'Resume a terminal task with a follow-up message.',
112
+ input.messageBanner,
113
+ ].filter(Boolean).join('\n\n'),
114
+ inputSchema: {
115
+ type: 'object',
116
+ properties: {
117
+ task_id: { type: 'string', minLength: 1 },
118
+ message: { type: 'string' },
119
+ timeout: { type: 'integer', minimum: 1, maximum: 3600000 },
120
+ cwd: { type: 'string' },
121
+ },
122
+ required: ['task_id'],
123
+ },
124
+ validate: (payload) => messageAgentSchema.parse(payload),
125
+ },
126
+ {
127
+ name: 'cancel-agent',
128
+ description: 'Cancel one task, many tasks, or clear all tracked tasks.',
129
+ inputSchema: {
130
+ type: 'object',
131
+ properties: {
132
+ task_id: {
133
+ type: ['string', 'array'],
134
+ items: { type: 'string', minLength: 1 },
135
+ minItems: 1,
136
+ maxItems: 50,
137
+ },
138
+ clear: { type: 'boolean' },
139
+ confirm: { type: 'boolean' },
140
+ },
141
+ required: ['task_id'],
142
+ },
143
+ validate: (payload) => cancelAgentSchema.parse(payload),
144
+ },
145
+ {
146
+ name: 'answer-agent',
147
+ description: [
148
+ 'Answer a pending agent question and resume execution.',
149
+ input.answerBanner,
150
+ ].filter(Boolean).join('\n\n'),
151
+ inputSchema: {
152
+ type: 'object',
153
+ properties: {
154
+ task_id: { type: 'string', minLength: 1 },
155
+ answer: { type: 'string', minLength: 1 },
156
+ answers: { type: 'object', additionalProperties: { type: 'string', minLength: 1 } },
157
+ },
158
+ required: ['task_id'],
159
+ },
160
+ validate: (payload) => answerAgentSchema.parse(payload),
161
+ },
162
+ ];
163
+ }
@@ -0,0 +1,305 @@
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 ModelInfo,
9
+ type CopilotSession,
10
+ } from '@github/copilot-sdk';
11
+
12
+ import { DEFAULT_MODEL, DEFAULT_TIMEOUT_MS } from '../config/defaults.js';
13
+ import { buildAgentPrompt } from '../templates/agent-prompt.js';
14
+ import type { TaskRecord } from '../types/task.js';
15
+ import { TaskStatus } from '../types/task.js';
16
+ import { startFleetModeIfEnabled } from './fleet-mode.js';
17
+ import {
18
+ buildModelCatalog,
19
+ describeAllowedModels,
20
+ resolveModelAlias,
21
+ type ModelCatalogInput,
22
+ } from './model-catalog.js';
23
+ import type { CopilotProfile } from './profile-manager.js';
24
+ import type { ProfileManager } from './profile-manager.js';
25
+ import type { QuestionRegistry } from './question-registry.js';
26
+ import type { TaskManager } from './task-manager.js';
27
+
28
+ export interface BuildCopilotClientOptionsInput {
29
+ cwd: string;
30
+ profile: CopilotProfile;
31
+ cliPath?: string | undefined;
32
+ }
33
+
34
+ function canExecute(path: string): boolean {
35
+ try {
36
+ accessSync(path, constants.X_OK);
37
+ return true;
38
+ } catch {
39
+ return false;
40
+ }
41
+ }
42
+
43
+ function resolveCopilotCliPath(explicitPath?: string): string {
44
+ if (explicitPath && canExecute(explicitPath)) {
45
+ return explicitPath;
46
+ }
47
+
48
+ const configuredPath = process.env.COPILOT_CLI_PATH;
49
+ if (configuredPath && canExecute(configuredPath)) {
50
+ return configuredPath;
51
+ }
52
+
53
+ for (const segment of (process.env.PATH ?? '').split(delimiter)) {
54
+ if (!segment) {
55
+ continue;
56
+ }
57
+
58
+ const candidate = join(segment, 'copilot');
59
+ if (canExecute(candidate)) {
60
+ return candidate;
61
+ }
62
+ }
63
+
64
+ return explicitPath ?? configuredPath ?? 'copilot';
65
+ }
66
+
67
+ export function buildCopilotClientOptions(
68
+ input: BuildCopilotClientOptionsInput,
69
+ ): CopilotClientOptions {
70
+ return {
71
+ cliPath: resolveCopilotCliPath(input.cliPath),
72
+ cliArgs: ['--config-dir', input.profile.configDir],
73
+ cwd: input.cwd,
74
+ useLoggedInUser: true,
75
+ useStdio: false,
76
+ autoStart: true,
77
+ autoRestart: false,
78
+ logLevel: 'error',
79
+ };
80
+ }
81
+
82
+ function isRateLimitError(error: unknown): boolean {
83
+ const message = error instanceof Error ? error.message : String(error);
84
+ const normalized = message.toLowerCase();
85
+ return (
86
+ normalized.includes('rate limit') ||
87
+ normalized.includes('too many requests') ||
88
+ normalized.includes('429') ||
89
+ normalized.includes('quota')
90
+ );
91
+ }
92
+
93
+ export class CopilotRuntime {
94
+ private readonly clients = new Map<string, CopilotClient>();
95
+ private readonly taskManager: TaskManager;
96
+ private readonly profileManager: ProfileManager;
97
+ private readonly questionRegistry: QuestionRegistry;
98
+
99
+ constructor(input: {
100
+ taskManager: TaskManager;
101
+ profileManager: ProfileManager;
102
+ questionRegistry: QuestionRegistry;
103
+ }) {
104
+ this.taskManager = input.taskManager;
105
+ this.profileManager = input.profileManager;
106
+ this.questionRegistry = input.questionRegistry;
107
+ }
108
+
109
+ async runTask(task: TaskRecord): Promise<void> {
110
+ try {
111
+ const profile = this.resolveProfile(task);
112
+ const client = await this.getClient(task.baseCwd ?? task.cwd, profile);
113
+ const prompt = await buildAgentPrompt({
114
+ agentType: task.agentType,
115
+ prompt: task.prompt,
116
+ contextFiles: task.contextFiles,
117
+ });
118
+
119
+ const session = task.sessionId
120
+ ? await client.resumeSession(task.sessionId, {
121
+ onPermissionRequest: approveAll,
122
+ onUserInputRequest: (request, invocation) =>
123
+ this.questionRegistry.register({
124
+ taskId: task.id,
125
+ sessionId: invocation.sessionId,
126
+ question: request.question,
127
+ choices: request.choices,
128
+ allowFreeform: request.allowFreeform,
129
+ }),
130
+ streaming: true,
131
+ })
132
+ : await client.createSession({
133
+ model: task.model,
134
+ onPermissionRequest: approveAll,
135
+ onUserInputRequest: (request, invocation) =>
136
+ this.questionRegistry.register({
137
+ taskId: task.id,
138
+ sessionId: invocation.sessionId,
139
+ question: request.question,
140
+ choices: request.choices,
141
+ allowFreeform: request.allowFreeform,
142
+ }),
143
+ streaming: true,
144
+ });
145
+
146
+ await this.taskManager.registerExecution(task.id, {
147
+ abort: async () => {
148
+ await session.abort().catch(() => {});
149
+ },
150
+ cleanup: async () => {
151
+ await session.disconnect().catch(() => {});
152
+ },
153
+ });
154
+
155
+ await this.taskManager.updateTask(task.id, {
156
+ status: TaskStatus.RUNNING,
157
+ sessionId: session.sessionId,
158
+ profileId: profile.id,
159
+ profileConfigDir: profile.configDir,
160
+ });
161
+
162
+ this.bindSessionEvents(task.id, session);
163
+
164
+ await this.taskManager.appendOutput(task.id, `[session] profile: ${profile.id} (${profile.configDir})`);
165
+ const fleetStarted = await startFleetModeIfEnabled(session, task.prompt);
166
+ if (fleetStarted) {
167
+ await this.taskManager.appendOutput(task.id, '[fleet] started');
168
+ }
169
+ await session.sendAndWait(
170
+ { prompt },
171
+ task.timeoutMs ?? DEFAULT_TIMEOUT_MS,
172
+ );
173
+ await this.taskManager.updateTask(task.id, {
174
+ status: TaskStatus.COMPLETED,
175
+ endTime: new Date().toISOString(),
176
+ });
177
+ } catch (error) {
178
+ if (isRateLimitError(error)) {
179
+ this.profileManager.markFailure('rate_limit');
180
+ await this.taskManager.updateTask(task.id, {
181
+ status: TaskStatus.RATE_LIMITED,
182
+ error: error instanceof Error ? error.message : String(error),
183
+ endTime: new Date().toISOString(),
184
+ });
185
+ } else {
186
+ await this.taskManager.updateTask(task.id, {
187
+ status: TaskStatus.FAILED,
188
+ error: error instanceof Error ? error.message : String(error),
189
+ endTime: new Date().toISOString(),
190
+ });
191
+ }
192
+ } finally {
193
+ await this.taskManager.clearExecution(task.id);
194
+ }
195
+ }
196
+
197
+ async shutdown(): Promise<void> {
198
+ await Promise.all(
199
+ [...this.clients.values()].map(async (client) => {
200
+ await client.stop().catch(async () => {
201
+ await client.forceStop().catch(() => {});
202
+ });
203
+ }),
204
+ );
205
+ this.clients.clear();
206
+ }
207
+
208
+ async resolveRequestedModel(cwd: string, requestedModel?: string): Promise<string> {
209
+ const profile = this.profileManager.getCurrentProfile();
210
+ const catalog = await this.loadModelCatalog(cwd, profile);
211
+ const rawModel = requestedModel ?? DEFAULT_MODEL;
212
+ const resolved = resolveModelAlias(rawModel, catalog);
213
+ if (!resolved) {
214
+ throw new Error(
215
+ `Unsupported model "${rawModel}" for profile ${profile.id} (${profile.configDir}). ${describeAllowedModels(catalog)}`,
216
+ );
217
+ }
218
+
219
+ return resolved;
220
+ }
221
+
222
+ async describeCurrentProfileModels(cwd: string): Promise<{
223
+ latestModelIds?: string[] | undefined;
224
+ description?: string | undefined;
225
+ }> {
226
+ try {
227
+ const profile = this.profileManager.getCurrentProfile();
228
+ const catalog = await this.loadModelCatalog(cwd, profile);
229
+ return {
230
+ latestModelIds: catalog.latestModelIds.length > 0 ? catalog.latestModelIds : undefined,
231
+ description: describeAllowedModels(catalog),
232
+ };
233
+ } catch (error) {
234
+ return {
235
+ description: `Copilot model id. Model inventory unavailable: ${error instanceof Error ? error.message : String(error)}`,
236
+ };
237
+ }
238
+ }
239
+
240
+ private bindSessionEvents(taskId: string, session: CopilotSession): void {
241
+ session.on('assistant.message', (event) => {
242
+ void this.taskManager.appendOutput(taskId, event.data.content);
243
+ });
244
+ session.on('tool.execution_start', (event) => {
245
+ void this.taskManager.appendOutput(taskId, `[tool] start: ${event.data.toolName}`);
246
+ });
247
+ session.on('tool.execution_complete', (event) => {
248
+ const label = 'toolName' in event.data ? event.data.toolName : event.data.toolCallId;
249
+ void this.taskManager.appendOutput(taskId, `[tool] complete: ${label} success=${String(event.data.success)}`);
250
+ });
251
+ session.on('session.error', (event) => {
252
+ void this.taskManager.appendOutput(taskId, `[error] ${event.data.message}`);
253
+ });
254
+ }
255
+
256
+ private async getClient(cwd: string, profile: CopilotProfile): Promise<CopilotClient> {
257
+ const key = `${cwd}:${profile.configDir}`;
258
+ const existing = this.clients.get(key);
259
+ if (existing) {
260
+ return existing;
261
+ }
262
+
263
+ const client = new CopilotClient(buildCopilotClientOptions({ cwd, profile }));
264
+ await client.start();
265
+ const auth = await client.getAuthStatus();
266
+ if (!auth.isAuthenticated) {
267
+ throw new Error(
268
+ `Copilot profile "${profile.configDir}" is not authenticated. Run \`copilot login --config-dir ${profile.configDir}\`.`,
269
+ );
270
+ }
271
+
272
+ this.clients.set(key, client);
273
+ return client;
274
+ }
275
+
276
+ private resolveProfile(task: TaskRecord): CopilotProfile {
277
+ if (task.profileConfigDir && task.profileId) {
278
+ const match = this.profileManager.getProfiles().find((profile) => profile.id === task.profileId);
279
+ if (match) {
280
+ return match;
281
+ }
282
+ return {
283
+ id: task.profileId,
284
+ configDir: task.profileConfigDir,
285
+ failureCount: 0,
286
+ };
287
+ }
288
+
289
+ return this.profileManager.getCurrentProfile();
290
+ }
291
+
292
+ private async loadModelCatalog(cwd: string, profile: CopilotProfile) {
293
+ const client = await this.getClient(cwd, profile);
294
+ const models = await client.listModels();
295
+ return buildModelCatalog(models.map((model) => this.toModelCatalogInput(model)));
296
+ }
297
+
298
+ private toModelCatalogInput(model: ModelInfo): ModelCatalogInput {
299
+ return {
300
+ id: model.id,
301
+ name: model.name,
302
+ policyState: model.policy?.state,
303
+ };
304
+ }
305
+ }
@@ -0,0 +1,39 @@
1
+ export const COPILOT_ENABLE_FLEET_ENV = 'COPILOT_ENABLE_FLEET';
2
+
3
+ const TRUTHY_VALUES = new Set(['1', 'true', 'yes', 'on']);
4
+
5
+ export interface FleetRpcSession {
6
+ rpc: {
7
+ fleet: {
8
+ start: (input: { prompt?: string }) => Promise<{ started: boolean }>;
9
+ };
10
+ };
11
+ }
12
+
13
+ export function isFleetModeEnabled(value = process.env[COPILOT_ENABLE_FLEET_ENV]): boolean {
14
+ return value !== undefined && TRUTHY_VALUES.has(value.trim().toLowerCase());
15
+ }
16
+
17
+ export function buildFleetPrompt(taskPrompt: string): string {
18
+ return [
19
+ 'You are one worker in a Copilot fleet assisting the primary agent.',
20
+ 'Work in parallel, focus on independently useful evidence, and surface concrete edits or commands the primary agent can apply immediately.',
21
+ 'Do not take ownership of the final deliverable unless the primary agent explicitly assigns a bounded subtask that requires it.',
22
+ 'Do not write the user-facing final answer or directly create the requested final artifact by default. Prefer scouting, verification, and support work that unblocks the primary agent.',
23
+ 'Do not ask the user questions. Do not restate the entire task. Stay concise and execution-focused.',
24
+ 'Primary task:',
25
+ taskPrompt.trim(),
26
+ ].join('\n\n');
27
+ }
28
+
29
+ export async function startFleetModeIfEnabled(session: FleetRpcSession, taskPrompt: string): Promise<boolean> {
30
+ if (!isFleetModeEnabled()) {
31
+ return false;
32
+ }
33
+
34
+ const result = await session.rpc.fleet.start({
35
+ prompt: buildFleetPrompt(taskPrompt),
36
+ });
37
+
38
+ return result.started;
39
+ }