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,136 @@
1
+ export interface ModelCatalogInput {
2
+ id: string;
3
+ name?: string | undefined;
4
+ policyState?: 'enabled' | 'disabled' | 'unconfigured' | undefined;
5
+ }
6
+
7
+ export interface ModelAliasMapping {
8
+ alias: string;
9
+ canonical: string;
10
+ }
11
+
12
+ export interface ModelCatalog {
13
+ latestModelIds: string[];
14
+ aliasMappings: ModelAliasMapping[];
15
+ }
16
+
17
+ const MANUALLY_EXCLUDED_MODEL_IDS = new Set(['gpt-4.1', 'claude-opus-4.6-fast']);
18
+
19
+ interface ParsedModelId {
20
+ family: string;
21
+ version: number[];
22
+ }
23
+
24
+ function isSelectableModel(model: ModelCatalogInput): boolean {
25
+ return (
26
+ model.policyState !== 'disabled' &&
27
+ model.policyState !== 'unconfigured' &&
28
+ !MANUALLY_EXCLUDED_MODEL_IDS.has(model.id)
29
+ );
30
+ }
31
+
32
+ function parseModelId(id: string): ParsedModelId | undefined {
33
+ const match = /^(.*)-(\d+(?:\.\d+)?)(?:-(.+))?$/.exec(id);
34
+ if (!match) {
35
+ return undefined;
36
+ }
37
+
38
+ const prefix = match[1];
39
+ const version = match[2];
40
+ const suffix = match[3];
41
+ if (!prefix || !version) {
42
+ return undefined;
43
+ }
44
+
45
+ const major = version.split('.')[0];
46
+ const familyBase = prefix.includes('-') ? prefix : `${prefix}-${major}`;
47
+ return {
48
+ family: suffix ? `${familyBase}-${suffix}` : familyBase,
49
+ version: version.split('.').map((part) => Number.parseInt(part, 10)),
50
+ };
51
+ }
52
+
53
+ function compareVersions(left: number[], right: number[]): number {
54
+ const length = Math.max(left.length, right.length);
55
+ for (let index = 0; index < length; index += 1) {
56
+ const leftPart = left[index] ?? 0;
57
+ const rightPart = right[index] ?? 0;
58
+ if (leftPart !== rightPart) {
59
+ return leftPart - rightPart;
60
+ }
61
+ }
62
+
63
+ return 0;
64
+ }
65
+
66
+ export function buildModelCatalog(models: ModelCatalogInput[]): ModelCatalog {
67
+ const selectable = models.filter(isSelectableModel);
68
+ const latestByFamily = new Map<string, { id: string; version: number[] }>();
69
+ const passthrough = new Set<string>();
70
+
71
+ for (const model of selectable) {
72
+ const parsed = parseModelId(model.id);
73
+ if (!parsed) {
74
+ passthrough.add(model.id);
75
+ continue;
76
+ }
77
+
78
+ const existing = latestByFamily.get(parsed.family);
79
+ if (!existing || compareVersions(parsed.version, existing.version) > 0) {
80
+ latestByFamily.set(parsed.family, { id: model.id, version: parsed.version });
81
+ }
82
+ }
83
+
84
+ const aliasMappings = selectable
85
+ .map((model) => {
86
+ const parsed = parseModelId(model.id);
87
+ if (!parsed) {
88
+ return undefined;
89
+ }
90
+
91
+ const canonical = latestByFamily.get(parsed.family)?.id;
92
+ if (!canonical || canonical === model.id) {
93
+ return undefined;
94
+ }
95
+
96
+ return {
97
+ alias: model.id,
98
+ canonical,
99
+ };
100
+ })
101
+ .filter((mapping): mapping is ModelAliasMapping => mapping !== undefined)
102
+ .sort((left, right) => left.alias.localeCompare(right.alias));
103
+
104
+ const latestModelIds = [
105
+ ...new Set([
106
+ ...passthrough,
107
+ ...[...latestByFamily.values()].map((entry) => entry.id),
108
+ ]),
109
+ ].sort((left, right) => left.localeCompare(right));
110
+
111
+ return {
112
+ latestModelIds,
113
+ aliasMappings,
114
+ };
115
+ }
116
+
117
+ export function resolveModelAlias(requestedModel: string, catalog: ModelCatalog): string | undefined {
118
+ if (catalog.latestModelIds.includes(requestedModel)) {
119
+ return requestedModel;
120
+ }
121
+
122
+ return catalog.aliasMappings.find((mapping) => mapping.alias === requestedModel)?.canonical;
123
+ }
124
+
125
+ export function describeAllowedModels(catalog: ModelCatalog): string {
126
+ const allowed = catalog.latestModelIds.join(', ');
127
+ if (catalog.aliasMappings.length === 0) {
128
+ return `Allowed models: ${allowed}`;
129
+ }
130
+
131
+ const aliases = catalog.aliasMappings
132
+ .map((mapping) => `${mapping.alias} -> ${mapping.canonical}`)
133
+ .join(', ');
134
+
135
+ return `Allowed models: ${allowed}. Accepted legacy aliases: ${aliases}`;
136
+ }
@@ -0,0 +1,68 @@
1
+ import { mkdir, open, readFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+
4
+ const OUTPUT_DIR_NAME = '.super-agents';
5
+
6
+ function sanitizeTaskId(taskId: string): string {
7
+ return taskId.replace(/[\/\\:\x00]/g, '_');
8
+ }
9
+
10
+ export function getOutputDir(cwd: string): string {
11
+ return join(cwd, OUTPUT_DIR_NAME);
12
+ }
13
+
14
+ export function getOutputPath(cwd: string, taskId: string): string {
15
+ return join(getOutputDir(cwd), `${sanitizeTaskId(taskId)}.output`);
16
+ }
17
+
18
+ function timestamp(): string {
19
+ return new Date().toISOString();
20
+ }
21
+
22
+ export class OutputLogService {
23
+ async create(cwd: string, taskId: string): Promise<string> {
24
+ await mkdir(getOutputDir(cwd), { recursive: true });
25
+
26
+ const outputPath = getOutputPath(cwd, taskId);
27
+ const handle = await open(outputPath, 'a+');
28
+ try {
29
+ const existing = await readFile(outputPath, 'utf8').catch(() => '');
30
+ if (!existing) {
31
+ await handle.writeFile(
32
+ `# Task: ${taskId}\n# Started: ${timestamp()}\n# Working directory: ${cwd}\n${'─'.repeat(60)}\n`,
33
+ 'utf8',
34
+ );
35
+ }
36
+ } finally {
37
+ await handle.close();
38
+ }
39
+
40
+ return outputPath;
41
+ }
42
+
43
+ async append(cwd: string, taskId: string, line: string): Promise<void> {
44
+ const outputPath = await this.create(cwd, taskId);
45
+ const handle = await open(outputPath, 'a');
46
+ try {
47
+ await handle.writeFile(`[${timestamp()}] ${line}\n`, 'utf8');
48
+ } finally {
49
+ await handle.close();
50
+ }
51
+ }
52
+
53
+ async finalize(cwd: string, taskId: string, status: string, error?: string): Promise<void> {
54
+ const outputPath = await this.create(cwd, taskId);
55
+ const handle = await open(outputPath, 'a');
56
+ try {
57
+ const footer = [
58
+ `${'─'.repeat(60)}`,
59
+ `# Completed: ${timestamp()}`,
60
+ `# Status: ${status}`,
61
+ error ? `# Error: ${error}` : null,
62
+ ].filter(Boolean).join('\n');
63
+ await handle.writeFile(`${footer}\n`, 'utf8');
64
+ } finally {
65
+ await handle.close();
66
+ }
67
+ }
68
+ }
@@ -0,0 +1,165 @@
1
+ import { homedir } from 'node:os';
2
+ import { join } from 'node:path';
3
+
4
+ import type { PersistedProfileState } from '../types/task.js';
5
+
6
+ export interface CopilotProfile {
7
+ id: string;
8
+ configDir: string;
9
+ cooldownUntil?: number | undefined;
10
+ failureCount: number;
11
+ lastFailureReason?: string | undefined;
12
+ }
13
+
14
+ export interface ProfileManagerOptions {
15
+ profileDirs?: string[] | undefined;
16
+ cooldownMs?: number | undefined;
17
+ now?: (() => number) | undefined;
18
+ persistedProfiles?: PersistedProfileState[] | undefined;
19
+ }
20
+
21
+ export interface ProfileRotationResult {
22
+ success: boolean;
23
+ allExhausted?: boolean | undefined;
24
+ currentProfile?: CopilotProfile | undefined;
25
+ }
26
+
27
+ const DEFAULT_COOLDOWN_MS = 60_000;
28
+
29
+ function defaultProfileDir(): string {
30
+ return join(homedir(), '.copilot');
31
+ }
32
+
33
+ function dedupeProfileDirs(profileDirs: string[]): string[] {
34
+ const seen = new Set<string>();
35
+ const result: string[] = [];
36
+
37
+ for (const dir of profileDirs) {
38
+ const normalized = dir.trim();
39
+ if (!normalized || seen.has(normalized)) {
40
+ continue;
41
+ }
42
+ seen.add(normalized);
43
+ result.push(normalized);
44
+ }
45
+
46
+ return result.length > 0 ? result : [defaultProfileDir()];
47
+ }
48
+
49
+ export class ProfileManager {
50
+ private readonly cooldownMs: number;
51
+ private readonly now: () => number;
52
+ private readonly profiles: CopilotProfile[];
53
+ private currentIndex = 0;
54
+
55
+ constructor(options: ProfileManagerOptions = {}) {
56
+ this.cooldownMs = options.cooldownMs ?? DEFAULT_COOLDOWN_MS;
57
+ this.now = options.now ?? Date.now;
58
+
59
+ const configuredDirs = dedupeProfileDirs(options.profileDirs ?? [defaultProfileDir()]);
60
+ const persistedByDir = new Map(
61
+ (options.persistedProfiles ?? []).map((profile) => [profile.configDir, profile]),
62
+ );
63
+
64
+ this.profiles = configuredDirs.map((configDir, index) => {
65
+ const persisted = persistedByDir.get(configDir);
66
+ return {
67
+ id: persisted?.id ?? `profile-${index + 1}`,
68
+ configDir,
69
+ cooldownUntil: persisted?.cooldownUntil,
70
+ failureCount: persisted?.failureCount ?? 0,
71
+ lastFailureReason: persisted?.lastFailureReason,
72
+ };
73
+ });
74
+
75
+ this.currentIndex = this.findFirstAvailableIndex();
76
+ }
77
+
78
+ static fromEnvironment(now?: () => number): ProfileManager {
79
+ const raw = process.env.COPILOT_CONFIG_DIRS;
80
+ const dirs = raw
81
+ ? raw.split(',').map((entry) => entry.trim()).filter(Boolean)
82
+ : [defaultProfileDir()];
83
+
84
+ return new ProfileManager({ profileDirs: dirs, now });
85
+ }
86
+
87
+ hydrate(persistedProfiles: PersistedProfileState[]): void {
88
+ const persistedByDir = new Map(
89
+ persistedProfiles.map((profile) => [profile.configDir, profile]),
90
+ );
91
+
92
+ for (const profile of this.profiles) {
93
+ const persisted = persistedByDir.get(profile.configDir);
94
+ if (!persisted) {
95
+ continue;
96
+ }
97
+
98
+ profile.failureCount = persisted.failureCount;
99
+ profile.cooldownUntil = persisted.cooldownUntil;
100
+ profile.lastFailureReason = persisted.lastFailureReason;
101
+ }
102
+
103
+ this.resetExpiredCooldowns();
104
+ }
105
+
106
+ getProfiles(): CopilotProfile[] {
107
+ return this.profiles.map((profile) => ({ ...profile }));
108
+ }
109
+
110
+ getCurrentProfile(): CopilotProfile {
111
+ this.resetExpiredCooldowns();
112
+ return { ...this.profiles[this.currentIndex]! };
113
+ }
114
+
115
+ markFailure(reason: string): ProfileRotationResult {
116
+ const current = this.profiles[this.currentIndex];
117
+ if (!current) {
118
+ return { success: false, allExhausted: true };
119
+ }
120
+
121
+ current.failureCount += 1;
122
+ current.lastFailureReason = reason;
123
+ current.cooldownUntil = this.now() + this.cooldownMs;
124
+
125
+ const nextIndex = this.findFirstAvailableIndex();
126
+ if (nextIndex === -1) {
127
+ return { success: false, allExhausted: true };
128
+ }
129
+
130
+ this.currentIndex = nextIndex;
131
+ return {
132
+ success: true,
133
+ currentProfile: this.getCurrentProfile(),
134
+ };
135
+ }
136
+
137
+ resetExpiredCooldowns(): void {
138
+ const now = this.now();
139
+ for (const profile of this.profiles) {
140
+ if (profile.cooldownUntil !== undefined && profile.cooldownUntil <= now) {
141
+ profile.cooldownUntil = undefined;
142
+ }
143
+ }
144
+
145
+ const nextIndex = this.findFirstAvailableIndex();
146
+ if (nextIndex !== -1) {
147
+ this.currentIndex = nextIndex;
148
+ }
149
+ }
150
+
151
+ toPersistedState(): PersistedProfileState[] {
152
+ return this.profiles.map((profile) => ({
153
+ id: profile.id,
154
+ configDir: profile.configDir,
155
+ cooldownUntil: profile.cooldownUntil,
156
+ failureCount: profile.failureCount,
157
+ lastFailureReason: profile.lastFailureReason,
158
+ }));
159
+ }
160
+
161
+ private findFirstAvailableIndex(): number {
162
+ const now = this.now();
163
+ return this.profiles.findIndex((profile) => profile.cooldownUntil === undefined || profile.cooldownUntil <= now);
164
+ }
165
+ }
@@ -0,0 +1,169 @@
1
+ import type { PendingQuestion } from '../types/task.js';
2
+ import { TaskStatus } from '../types/task.js';
3
+ import type { TaskManager } from './task-manager.js';
4
+
5
+ interface RegisterQuestionInput {
6
+ taskId: string;
7
+ sessionId: string;
8
+ question: string;
9
+ choices?: string[] | undefined;
10
+ allowFreeform?: boolean | undefined;
11
+ }
12
+
13
+ interface PendingBinding {
14
+ question: PendingQuestion;
15
+ resolve: (value: { answer: string; wasFreeform: boolean }) => void;
16
+ reject: (reason?: unknown) => void;
17
+ timeout: NodeJS.Timeout;
18
+ }
19
+
20
+ const DEFAULT_TIMEOUT_MS = 30 * 60 * 1000;
21
+
22
+ export class QuestionRegistry {
23
+ private readonly taskManager: TaskManager;
24
+ private readonly timeoutMs: number;
25
+ private readonly pending = new Map<string, PendingBinding>();
26
+
27
+ constructor(taskManager: TaskManager, timeoutMs = DEFAULT_TIMEOUT_MS) {
28
+ this.taskManager = taskManager;
29
+ this.timeoutMs = timeoutMs;
30
+ }
31
+
32
+ register(input: RegisterQuestionInput): Promise<{ answer: string; wasFreeform: boolean }> {
33
+ this.clear(input.taskId, 'replaced by newer question');
34
+
35
+ return new Promise((resolve, reject) => {
36
+ const question: PendingQuestion = {
37
+ question: input.question,
38
+ choices: input.choices,
39
+ allowFreeform: input.allowFreeform ?? true,
40
+ askedAt: new Date().toISOString(),
41
+ sessionId: input.sessionId,
42
+ };
43
+
44
+ const timeout = setTimeout(() => {
45
+ void this.taskManager.updateTask(input.taskId, {
46
+ status: TaskStatus.FAILED,
47
+ error: 'Task failed: user question timed out after 30 minutes',
48
+ pendingQuestion: undefined,
49
+ endTime: new Date().toISOString(),
50
+ });
51
+ reject(new Error('Question timed out'));
52
+ this.pending.delete(input.taskId);
53
+ }, this.timeoutMs);
54
+ timeout.unref();
55
+
56
+ this.pending.set(input.taskId, { question, resolve, reject, timeout });
57
+ void this.taskManager.updateTask(input.taskId, {
58
+ status: TaskStatus.WAITING_ANSWER,
59
+ pendingQuestion: question,
60
+ });
61
+ void this.taskManager.appendOutput(
62
+ input.taskId,
63
+ `[question] ${input.question}`,
64
+ );
65
+ });
66
+ }
67
+
68
+ submitAnswer(
69
+ taskId: string,
70
+ rawAnswer: string,
71
+ ): { success: boolean; resolvedAnswer?: string | undefined; error?: string | undefined } {
72
+ const binding = this.pending.get(taskId);
73
+ if (!binding) {
74
+ return { success: false, error: 'No pending question for this task' };
75
+ }
76
+
77
+ const parsed = this.parseAnswer(rawAnswer, binding.question);
78
+ if (!parsed.success) {
79
+ return parsed;
80
+ }
81
+
82
+ clearTimeout(binding.timeout);
83
+ binding.resolve({ answer: parsed.resolvedAnswer!, wasFreeform: parsed.wasFreeform! });
84
+ this.pending.delete(taskId);
85
+ void this.taskManager.updateTask(taskId, {
86
+ status: TaskStatus.RUNNING,
87
+ pendingQuestion: undefined,
88
+ });
89
+ void this.taskManager.appendOutput(taskId, `[question] Answer submitted: ${parsed.resolvedAnswer}`);
90
+
91
+ return parsed;
92
+ }
93
+
94
+ hasPendingQuestion(taskId: string): boolean {
95
+ return this.pending.has(taskId);
96
+ }
97
+
98
+ getAllPendingQuestions(): Map<string, PendingQuestion> {
99
+ return new Map(
100
+ [...this.pending.entries()].map(([taskId, binding]) => [taskId, binding.question]),
101
+ );
102
+ }
103
+
104
+ clear(taskId: string, reason: string): void {
105
+ const binding = this.pending.get(taskId);
106
+ if (!binding) {
107
+ return;
108
+ }
109
+
110
+ clearTimeout(binding.timeout);
111
+ binding.reject(new Error(reason));
112
+ this.pending.delete(taskId);
113
+ void this.taskManager.updateTask(taskId, { pendingQuestion: undefined });
114
+ }
115
+
116
+ private parseAnswer(
117
+ rawAnswer: string,
118
+ question: PendingQuestion,
119
+ ): {
120
+ success: boolean;
121
+ resolvedAnswer?: string | undefined;
122
+ wasFreeform?: boolean | undefined;
123
+ error?: string | undefined;
124
+ } {
125
+ const answer = rawAnswer.trim();
126
+ if (!answer) {
127
+ return { success: false, error: 'Answer cannot be empty' };
128
+ }
129
+
130
+ if (question.choices && /^\d+$/.test(answer)) {
131
+ const index = Number.parseInt(answer, 10) - 1;
132
+ if (index < 0 || index >= question.choices.length) {
133
+ return { success: false, error: `Invalid choice. Please enter 1-${question.choices.length}.` };
134
+ }
135
+
136
+ return {
137
+ success: true,
138
+ resolvedAnswer: question.choices[index]!,
139
+ wasFreeform: false,
140
+ };
141
+ }
142
+
143
+ if (answer.toUpperCase().startsWith('OTHER:') && question.allowFreeform) {
144
+ return {
145
+ success: true,
146
+ resolvedAnswer: answer.slice('OTHER:'.length).trim(),
147
+ wasFreeform: true,
148
+ };
149
+ }
150
+
151
+ if (question.choices?.includes(answer)) {
152
+ return {
153
+ success: true,
154
+ resolvedAnswer: answer,
155
+ wasFreeform: false,
156
+ };
157
+ }
158
+
159
+ if (question.allowFreeform) {
160
+ return {
161
+ success: true,
162
+ resolvedAnswer: answer,
163
+ wasFreeform: true,
164
+ };
165
+ }
166
+
167
+ return { success: false, error: 'Invalid answer' };
168
+ }
169
+ }
@@ -0,0 +1,75 @@
1
+ import { stat } from 'node:fs/promises';
2
+ import { resolve } from 'node:path';
3
+
4
+ import type { ContextFile } from '../types/task.js';
5
+ import type { SpawnAgentInput } from '../mcp/tool-definitions.js';
6
+
7
+ const MAX_CONTEXT_FILES = 20;
8
+ const MAX_CONTEXT_FILE_BYTES = 200 * 1024;
9
+ const MAX_TOTAL_CONTEXT_BYTES = 500 * 1024;
10
+
11
+ const MIN_PROMPT_LENGTH: Record<SpawnAgentInput['agent_type'], number> = {
12
+ coder: 1000,
13
+ planner: 300,
14
+ researcher: 200,
15
+ tester: 300,
16
+ general: 200,
17
+ };
18
+
19
+ export interface ValidateSpawnAgentInputArgs {
20
+ input: SpawnAgentInput;
21
+ baseCwd: string;
22
+ }
23
+
24
+ export async function validateSpawnAgentInput(
25
+ args: ValidateSpawnAgentInputArgs,
26
+ ): Promise<{ contextFiles: ContextFile[] | undefined }> {
27
+ const minimum = MIN_PROMPT_LENGTH[args.input.agent_type];
28
+ if (args.input.prompt.trim().length < minimum) {
29
+ throw new Error(
30
+ `${args.input.agent_type} tasks require a prompt of at least ${minimum} characters`,
31
+ );
32
+ }
33
+
34
+ const inputFiles = args.input.context_files;
35
+ if (args.input.agent_type === 'coder' && (inputFiles?.length ?? 0) < 1) {
36
+ throw new Error('coder tasks require at least one context_files entry');
37
+ }
38
+
39
+ if (!inputFiles || inputFiles.length === 0) {
40
+ return { contextFiles: undefined };
41
+ }
42
+
43
+ if (inputFiles.length > MAX_CONTEXT_FILES) {
44
+ throw new Error(`context_files may contain at most ${MAX_CONTEXT_FILES} files`);
45
+ }
46
+
47
+ const normalized: ContextFile[] = [];
48
+ let totalBytes = 0;
49
+
50
+ for (const file of inputFiles) {
51
+ const absolutePath = resolve(args.baseCwd, file.path);
52
+ const info = await stat(absolutePath).catch(() => null);
53
+ if (!info) {
54
+ throw new Error(`Context file not found: ${file.path}`);
55
+ }
56
+ if (!info.isFile()) {
57
+ throw new Error(`Context file is not a regular file: ${file.path}`);
58
+ }
59
+ if (info.size > MAX_CONTEXT_FILE_BYTES) {
60
+ throw new Error(`Context file exceeds 200KB: ${file.path}`);
61
+ }
62
+
63
+ totalBytes += info.size;
64
+ if (totalBytes > MAX_TOTAL_CONTEXT_BYTES) {
65
+ throw new Error('Combined context_files size exceeds 500KB');
66
+ }
67
+
68
+ normalized.push({
69
+ path: absolutePath,
70
+ description: file.description,
71
+ });
72
+ }
73
+
74
+ return { contextFiles: normalized };
75
+ }