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.
Files changed (79) hide show
  1. package/README.md +207 -0
  2. package/bin/mcp-copilot-cli.mjs +3 -0
  3. package/dist/src/app.d.ts +71 -0
  4. package/dist/src/app.js +303 -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 +109 -0
  25. package/dist/src/mcp/tool-definitions.js +125 -0
  26. package/dist/src/mcp/tool-definitions.js.map +1 -0
  27. package/dist/src/services/copilot-runtime.d.ts +28 -0
  28. package/dist/src/services/copilot-runtime.js +194 -0
  29. package/dist/src/services/copilot-runtime.js.map +1 -0
  30. package/dist/src/services/output-log.d.ts +7 -0
  31. package/dist/src/services/output-log.js +59 -0
  32. package/dist/src/services/output-log.js.map +1 -0
  33. package/dist/src/services/profile-manager.d.ts +34 -0
  34. package/dist/src/services/profile-manager.js +113 -0
  35. package/dist/src/services/profile-manager.js.map +1 -0
  36. package/dist/src/services/question-registry.d.ts +29 -0
  37. package/dist/src/services/question-registry.js +115 -0
  38. package/dist/src/services/question-registry.js.map +1 -0
  39. package/dist/src/services/spawn-validation.d.ts +9 -0
  40. package/dist/src/services/spawn-validation.js +53 -0
  41. package/dist/src/services/spawn-validation.js.map +1 -0
  42. package/dist/src/services/task-manager.d.ts +34 -0
  43. package/dist/src/services/task-manager.js +107 -0
  44. package/dist/src/services/task-manager.js.map +1 -0
  45. package/dist/src/services/task-persistence.d.ts +20 -0
  46. package/dist/src/services/task-persistence.js +67 -0
  47. package/dist/src/services/task-persistence.js.map +1 -0
  48. package/dist/src/services/task-store.d.ts +12 -0
  49. package/dist/src/services/task-store.js +167 -0
  50. package/dist/src/services/task-store.js.map +1 -0
  51. package/dist/src/services/workspace-isolation.d.ts +13 -0
  52. package/dist/src/services/workspace-isolation.js +74 -0
  53. package/dist/src/services/workspace-isolation.js.map +1 -0
  54. package/dist/src/templates/agent-prompt.d.ts +6 -0
  55. package/dist/src/templates/agent-prompt.js +58 -0
  56. package/dist/src/templates/agent-prompt.js.map +1 -0
  57. package/dist/src/types/task.d.ts +79 -0
  58. package/dist/src/types/task.js +22 -0
  59. package/dist/src/types/task.js.map +1 -0
  60. package/package.json +63 -0
  61. package/src/app.ts +341 -0
  62. package/src/cli/doctor.ts +112 -0
  63. package/src/config/defaults.ts +3 -0
  64. package/src/index.ts +128 -0
  65. package/src/mcp/system-status.ts +41 -0
  66. package/src/mcp/task-markdown.ts +81 -0
  67. package/src/mcp/tool-banners.ts +32 -0
  68. package/src/mcp/tool-definitions.ts +151 -0
  69. package/src/services/copilot-runtime.ts +247 -0
  70. package/src/services/output-log.ts +68 -0
  71. package/src/services/profile-manager.ts +165 -0
  72. package/src/services/question-registry.ts +169 -0
  73. package/src/services/spawn-validation.ts +75 -0
  74. package/src/services/task-manager.ts +144 -0
  75. package/src/services/task-persistence.ts +100 -0
  76. package/src/services/task-store.ts +207 -0
  77. package/src/services/workspace-isolation.ts +100 -0
  78. package/src/templates/agent-prompt.ts +71 -0
  79. package/src/types/task.ts +95 -0
@@ -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
+ }
@@ -0,0 +1,144 @@
1
+ import type { PersistedProfileState } from '../types/task.js';
2
+ import type { OutputLogService } from './output-log.js';
3
+ import { TaskStore } from './task-store.js';
4
+ import {
5
+ loadWorkspaceState,
6
+ saveWorkspaceState,
7
+ type LoadedWorkspaceState,
8
+ } from './task-persistence.js';
9
+ import type { CreateTaskInput, TaskRecord } from '../types/task.js';
10
+ import { TaskStatus, isTerminalStatus } from '../types/task.js';
11
+
12
+ export interface TaskManagerOptions {
13
+ storageRoot?: string | undefined;
14
+ outputLog: OutputLogService;
15
+ getProfiles?: (() => PersistedProfileState[]) | undefined;
16
+ }
17
+
18
+ type ExecutionBinding = {
19
+ abort?: () => Promise<void>;
20
+ cleanup?: () => Promise<void>;
21
+ };
22
+
23
+ export class TaskManager {
24
+ private readonly store = new TaskStore();
25
+ private readonly storageRoot: string | undefined;
26
+ private readonly outputLog: OutputLogService;
27
+ private readonly getProfiles: (() => PersistedProfileState[]) | undefined;
28
+ private readonly executionBindings = new Map<string, ExecutionBinding>();
29
+
30
+ constructor(options: TaskManagerOptions) {
31
+ this.storageRoot = options.storageRoot;
32
+ this.outputLog = options.outputLog;
33
+ this.getProfiles = options.getProfiles;
34
+ }
35
+
36
+ async load(cwd: string): Promise<LoadedWorkspaceState> {
37
+ const restored = await loadWorkspaceState({ cwd, storageRoot: this.storageRoot });
38
+ for (const task of restored.tasks) {
39
+ this.store.importTask(task);
40
+ }
41
+ return restored;
42
+ }
43
+
44
+ getTask(taskId: string): TaskRecord | undefined {
45
+ return this.store.getTask(taskId);
46
+ }
47
+
48
+ getAllTasks(): TaskRecord[] {
49
+ return this.store.getAllTasks();
50
+ }
51
+
52
+ async createTask(input: CreateTaskInput): Promise<TaskRecord> {
53
+ const task = this.store.createTask(input);
54
+ task.outputFilePath = await this.outputLog.create(task.cwd, task.id);
55
+ await this.persist(task.cwd);
56
+ return task;
57
+ }
58
+
59
+ async updateTask(taskId: string, updates: Partial<TaskRecord>): Promise<TaskRecord | undefined> {
60
+ const task = this.store.updateTask(taskId, updates);
61
+ if (!task) {
62
+ return undefined;
63
+ }
64
+
65
+ if (task.outputFilePath && updates.status && isTerminalStatus(updates.status)) {
66
+ await this.outputLog.finalize(task.cwd, task.id, task.status, task.error);
67
+ }
68
+
69
+ await this.persist(task.cwd);
70
+ return task;
71
+ }
72
+
73
+ async appendOutput(taskId: string, line: string): Promise<void> {
74
+ const task = this.store.getTask(taskId);
75
+ if (!task) {
76
+ return;
77
+ }
78
+
79
+ task.output.push(line);
80
+ task.updatedAt = new Date().toISOString();
81
+ await this.outputLog.append(task.cwd, task.id, line);
82
+ await this.persist(task.cwd);
83
+ }
84
+
85
+ async releaseReadyTasks(): Promise<TaskRecord[]> {
86
+ const tasks = this.store.releaseReadyTasks();
87
+ if (tasks.length > 0) {
88
+ await this.persist(tasks[0]!.cwd);
89
+ }
90
+ return tasks;
91
+ }
92
+
93
+ async registerExecution(taskId: string, binding: ExecutionBinding): Promise<void> {
94
+ this.executionBindings.set(taskId, binding);
95
+ }
96
+
97
+ async clearExecution(taskId: string): Promise<void> {
98
+ const binding = this.executionBindings.get(taskId);
99
+ if (!binding) {
100
+ return;
101
+ }
102
+
103
+ await binding.cleanup?.();
104
+ this.executionBindings.delete(taskId);
105
+ }
106
+
107
+ async cancelTask(taskId: string): Promise<boolean> {
108
+ const task = this.store.getTask(taskId);
109
+ if (!task || isTerminalStatus(task.status)) {
110
+ return false;
111
+ }
112
+
113
+ const binding = this.executionBindings.get(taskId);
114
+ await binding?.abort?.();
115
+ await this.clearExecution(taskId);
116
+ await this.updateTask(taskId, {
117
+ status: TaskStatus.CANCELLED,
118
+ endTime: new Date().toISOString(),
119
+ });
120
+
121
+ return true;
122
+ }
123
+
124
+ async clearAllTasks(): Promise<number> {
125
+ const tasks = this.store.getAllTasks();
126
+ for (const task of tasks) {
127
+ await this.cancelTask(task.id).catch(() => {});
128
+ }
129
+ this.store.clear();
130
+ if (tasks[0]) {
131
+ await this.persist(tasks[0].cwd);
132
+ }
133
+ return tasks.length;
134
+ }
135
+
136
+ private async persist(cwd: string): Promise<void> {
137
+ await saveWorkspaceState({
138
+ cwd,
139
+ storageRoot: this.storageRoot,
140
+ tasks: this.store.getAllTasks(),
141
+ profiles: this.getProfiles?.() ?? [],
142
+ });
143
+ }
144
+ }