omp-worker-mcp 0.1.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/dist/index.js ADDED
@@ -0,0 +1,892 @@
1
+ import { spawn } from "node:child_process";
2
+ import { stat } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { setTimeout as sleep } from "node:timers/promises";
5
+ import { fileURLToPath } from "node:url";
6
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
7
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
8
+ import { z } from "zod";
9
+ import { buildContinuePrompt, buildDelegatePrompt } from "./protocol.js";
10
+ import { cleanState, clearCancellationRequest, createGroupId, createJobId, ensureGroupDirectory, ensureJobDirectory, getRetentionOptionsFromEnv, jobFilePath, readGroup, readJob, writeCancellationRequest, writeGroup, writeGroupCancellationRequest, writeJob, writePrompt, } from "./job-store.js";
11
+ import { GROUP_TERMINAL_STATUSES, TERMINAL_STATUSES, } from "./types.js";
12
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
13
+ const runnerPath = path.join(__dirname, "runner.js");
14
+ const groupRunnerPath = path.join(__dirname, "group-runner.js");
15
+ const artifactSchema = z.object({ path: z.string(), description: z.string() });
16
+ const statusOutput = {
17
+ job_id: z.string(),
18
+ status: z.string(),
19
+ attempt: z.number().int(),
20
+ max_attempts: z.number().int(),
21
+ session_id: z.string().optional(),
22
+ summary: z.string().optional(),
23
+ artifacts: z.array(artifactSchema),
24
+ verification: z.array(z.string()),
25
+ remaining: z.array(z.string()),
26
+ error: z.string().optional(),
27
+ details_path: z.string().optional(),
28
+ };
29
+ const batchTaskResultSchema = z.object({
30
+ id: z.string(),
31
+ status: z.enum(["completed", "failed", "cancelled", "timed_out", "blocked"]),
32
+ job_id: z.string().optional(),
33
+ summary: z.string().optional(),
34
+ artifacts: z.array(artifactSchema),
35
+ verification: z.array(z.string()),
36
+ remaining: z.array(z.string()),
37
+ error: z.string().optional(),
38
+ details_path: z.string().optional(),
39
+ });
40
+ const batchStatusOutput = {
41
+ group_id: z.string(),
42
+ status: z.enum([
43
+ "queued",
44
+ "running",
45
+ "cancelling",
46
+ "completed",
47
+ "partial",
48
+ "failed",
49
+ "timed_out",
50
+ "cancelled",
51
+ ]),
52
+ max_parallel: z.number().int(),
53
+ total_tasks: z.number().int(),
54
+ completed_tasks: z.number().int(),
55
+ failed_tasks: z.number().int(),
56
+ cancelled_tasks: z.number().int(),
57
+ blocked_tasks: z.number().int(),
58
+ summary: z.string(),
59
+ tasks: z.array(batchTaskResultSchema).optional(),
60
+ };
61
+ function compactJob(job) {
62
+ return {
63
+ job_id: job.id,
64
+ status: job.status,
65
+ attempt: job.currentAttempt,
66
+ max_attempts: job.maxAttempts,
67
+ session_id: job.sessionId,
68
+ summary: job.summary,
69
+ artifacts: job.artifacts,
70
+ verification: job.verification,
71
+ remaining: job.remaining,
72
+ error: job.error,
73
+ details_path: TERMINAL_STATUSES.has(job.status) ? jobFilePath(job.id) : undefined,
74
+ };
75
+ }
76
+ function toolResult(job, message) {
77
+ const structuredContent = compactJob(job);
78
+ return {
79
+ content: [{ type: "text", text: message || JSON.stringify(structuredContent, null, 2) }],
80
+ structuredContent,
81
+ };
82
+ }
83
+ function groupToolResult(structuredContent, message) {
84
+ return {
85
+ content: [{ type: "text", text: message || JSON.stringify(structuredContent, null, 2) }],
86
+ structuredContent,
87
+ };
88
+ }
89
+ async function buildCompactGroupResult(group, includeTasks, customSummary) {
90
+ let completedCount = 0;
91
+ let failedCount = 0;
92
+ let cancelledCount = 0;
93
+ let blockedCount = 0;
94
+ let timedOutCount = 0;
95
+ for (const t of group.tasks) {
96
+ if (t.status === "completed")
97
+ completedCount++;
98
+ else if (t.status === "failed")
99
+ failedCount++;
100
+ else if (t.status === "cancelled")
101
+ cancelledCount++;
102
+ else if (t.status === "blocked")
103
+ blockedCount++;
104
+ else if (t.status === "timed_out")
105
+ timedOutCount++;
106
+ }
107
+ const totalTasks = group.tasks.length;
108
+ let defaultSummary;
109
+ if (GROUP_TERMINAL_STATUSES.has(group.status)) {
110
+ defaultSummary =
111
+ group.summary ||
112
+ `Batch execution ${group.id} finished with status '${group.status}': ${completedCount}/${totalTasks} tasks completed, ${failedCount} failed, ${blockedCount} blocked, ${cancelledCount} cancelled, ${timedOutCount} timed out.`;
113
+ }
114
+ else {
115
+ defaultSummary = `Batch group ${group.id} is currently '${group.status}': ${completedCount}/${totalTasks} tasks completed, ${failedCount} failed, ${blockedCount} blocked, ${cancelledCount} cancelled. Use omp_wait_group to wait for completion.`;
116
+ }
117
+ let taskResults;
118
+ if (includeTasks) {
119
+ taskResults = [];
120
+ for (const t of group.tasks) {
121
+ if (t.jobId) {
122
+ try {
123
+ const job = await readJob(t.jobId);
124
+ taskResults.push({
125
+ id: t.id,
126
+ status: (t.status === "completed" ||
127
+ t.status === "failed" ||
128
+ t.status === "cancelled" ||
129
+ t.status === "timed_out" ||
130
+ t.status === "blocked"
131
+ ? t.status
132
+ : "failed"),
133
+ job_id: job.id,
134
+ summary: job.summary,
135
+ artifacts: job.artifacts || [],
136
+ verification: job.verification || [],
137
+ remaining: job.remaining || [],
138
+ error: t.error || job.error,
139
+ details_path: TERMINAL_STATUSES.has(job.status) ? jobFilePath(job.id) : undefined,
140
+ });
141
+ }
142
+ catch {
143
+ taskResults.push({
144
+ id: t.id,
145
+ status: (t.status === "completed" ||
146
+ t.status === "failed" ||
147
+ t.status === "cancelled" ||
148
+ t.status === "timed_out" ||
149
+ t.status === "blocked"
150
+ ? t.status
151
+ : "failed"),
152
+ job_id: t.jobId,
153
+ artifacts: [],
154
+ verification: [],
155
+ remaining: [],
156
+ error: t.error || "Failed to load job details",
157
+ });
158
+ }
159
+ }
160
+ else {
161
+ taskResults.push({
162
+ id: t.id,
163
+ status: (t.status === "completed" ||
164
+ t.status === "failed" ||
165
+ t.status === "cancelled" ||
166
+ t.status === "timed_out" ||
167
+ t.status === "blocked"
168
+ ? t.status
169
+ : "failed"),
170
+ artifacts: [],
171
+ verification: [],
172
+ remaining: [],
173
+ error: t.error,
174
+ });
175
+ }
176
+ }
177
+ }
178
+ const structuredContent = {
179
+ group_id: group.id,
180
+ status: group.status,
181
+ max_parallel: group.maxParallel,
182
+ total_tasks: totalTasks,
183
+ completed_tasks: completedCount,
184
+ failed_tasks: failedCount,
185
+ cancelled_tasks: cancelledCount,
186
+ blocked_tasks: blockedCount,
187
+ summary: customSummary || defaultSummary,
188
+ };
189
+ if (taskResults !== undefined) {
190
+ structuredContent.tasks = taskResults;
191
+ }
192
+ return structuredContent;
193
+ }
194
+ async function waitForGroup(groupId, waitSeconds, signal) {
195
+ let group = await readGroup(groupId);
196
+ if (GROUP_TERMINAL_STATUSES.has(group.status) || waitSeconds <= 0) {
197
+ return group;
198
+ }
199
+ const deadline = Date.now() + waitSeconds * 1_000;
200
+ while (!GROUP_TERMINAL_STATUSES.has(group.status) && Date.now() < deadline) {
201
+ if (signal?.aborted) {
202
+ break;
203
+ }
204
+ await sleep(Math.min(200, Math.max(10, deadline - Date.now())));
205
+ try {
206
+ group = await readGroup(groupId);
207
+ }
208
+ catch (error) {
209
+ if (Date.now() >= deadline)
210
+ throw error;
211
+ }
212
+ }
213
+ return group;
214
+ }
215
+ async function validateWorkingDirectory(cwd) {
216
+ if (!path.isAbsolute(cwd))
217
+ throw new Error("cwd must be an absolute path");
218
+ const resolved = path.resolve(cwd);
219
+ const info = await stat(resolved).catch((error) => {
220
+ if (error.code === "ENOENT")
221
+ throw new Error(`cwd does not exist: ${resolved}`);
222
+ throw error;
223
+ });
224
+ if (!info.isDirectory())
225
+ throw new Error(`cwd is not a directory: ${resolved}`);
226
+ return resolved;
227
+ }
228
+ async function launchRunner(job) {
229
+ const child = spawn(process.execPath, [runnerPath, jobFilePath(job.id)], {
230
+ detached: true,
231
+ windowsHide: true,
232
+ stdio: "ignore",
233
+ env: process.env,
234
+ });
235
+ child.on("error", (error) => {
236
+ console.error(`Failed to launch runner for job ${job.id}:`, error);
237
+ });
238
+ child.unref();
239
+ }
240
+ async function launchGroupRunner(groupId) {
241
+ const child = spawn(process.execPath, [groupRunnerPath, groupId], {
242
+ detached: true,
243
+ windowsHide: true,
244
+ stdio: "ignore",
245
+ env: process.env,
246
+ });
247
+ child.on("error", (error) => {
248
+ console.error(`Failed to launch group runner for group ${groupId}:`, error);
249
+ });
250
+ child.unref();
251
+ }
252
+ function attemptPaths(directory, number) {
253
+ const prefix = `attempt-${String(number).padStart(2, "0")}`;
254
+ return {
255
+ stdoutPath: path.join(directory, `${prefix}.stdout.jsonl`),
256
+ stderrPath: path.join(directory, `${prefix}.stderr.log`),
257
+ };
258
+ }
259
+ async function createAndStartJob(params) {
260
+ const resolvedCwd = await validateWorkingDirectory(params.cwd);
261
+ const id = createJobId();
262
+ const directory = await ensureJobDirectory(id);
263
+ const now = new Date().toISOString();
264
+ const job = {
265
+ version: 1,
266
+ id,
267
+ status: "queued",
268
+ goal: params.goal,
269
+ supervisorBrief: params.supervisor_brief,
270
+ cwd: resolvedCwd,
271
+ acceptance: params.acceptance,
272
+ maxAttempts: params.max_attempts,
273
+ currentAttempt: 1,
274
+ createdAt: now,
275
+ updatedAt: now,
276
+ artifacts: [],
277
+ verification: [],
278
+ remaining: [],
279
+ attempts: [],
280
+ groupId: params.groupId,
281
+ groupTaskId: params.groupTaskId,
282
+ access: params.access,
283
+ ownership: params.ownership,
284
+ };
285
+ const promptPath = await writePrompt(id, 1, buildDelegatePrompt(job));
286
+ const paths = attemptPaths(directory, 1);
287
+ const attempt = {
288
+ number: 1,
289
+ kind: "delegate",
290
+ status: "queued",
291
+ promptPath,
292
+ timeoutMinutes: params.timeout_minutes,
293
+ ...paths,
294
+ };
295
+ job.attempts.push(attempt);
296
+ await writeJob(job);
297
+ try {
298
+ await launchRunner(job);
299
+ }
300
+ catch (error) {
301
+ job.status = "failed";
302
+ job.error = error instanceof Error ? error.message : String(error);
303
+ attempt.status = "failed";
304
+ attempt.error = job.error;
305
+ await writeJob(job);
306
+ throw error;
307
+ }
308
+ return { job, directory };
309
+ }
310
+ function normalizeFilePath(filePath, cwd) {
311
+ const resolved = path.isAbsolute(filePath) ? path.resolve(filePath) : path.resolve(cwd, filePath);
312
+ return resolved.replace(/\\/g, "/").toLowerCase();
313
+ }
314
+ function checkPathOverlap(p1, p2) {
315
+ if (p1 === p2)
316
+ return true;
317
+ const p1WithSlash = p1.endsWith("/") ? p1 : `${p1}/`;
318
+ const p2WithSlash = p2.endsWith("/") ? p2 : `${p2}/`;
319
+ return p2.startsWith(p1WithSlash) || p1.startsWith(p2WithSlash);
320
+ }
321
+ export function validateDAGAndOwnership(tasks, cwd) {
322
+ const taskMap = new Map();
323
+ for (const t of tasks) {
324
+ if (taskMap.has(t.id)) {
325
+ throw new Error(`Duplicate task id: ${t.id}`);
326
+ }
327
+ taskMap.set(t.id, t);
328
+ }
329
+ for (const t of tasks) {
330
+ const deps = t.depends_on || [];
331
+ for (const dep of deps) {
332
+ if (!taskMap.has(dep)) {
333
+ throw new Error(`Task ${t.id} depends on unknown task: ${dep}`);
334
+ }
335
+ if (dep === t.id) {
336
+ throw new Error(`Task ${t.id} cannot depend on itself`);
337
+ }
338
+ }
339
+ }
340
+ const visited = new Map();
341
+ function checkCycle(id, pathStack) {
342
+ visited.set(id, 1);
343
+ const deps = taskMap.get(id)?.depends_on || [];
344
+ for (const dep of deps) {
345
+ const state = visited.get(dep) || 0;
346
+ if (state === 1) {
347
+ throw new Error(`Cyclic dependency detected: ${[...pathStack, id, dep].join(" -> ")}`);
348
+ }
349
+ if (state === 0) {
350
+ checkCycle(dep, [...pathStack, id]);
351
+ }
352
+ }
353
+ visited.set(id, 2);
354
+ }
355
+ for (const t of tasks) {
356
+ if (!visited.get(t.id)) {
357
+ checkCycle(t.id, []);
358
+ }
359
+ }
360
+ const leadsTo = new Map();
361
+ for (const t of tasks) {
362
+ leadsTo.set(t.id, new Set());
363
+ }
364
+ for (const t of tasks) {
365
+ const deps = t.depends_on || [];
366
+ for (const dep of deps) {
367
+ leadsTo.get(dep).add(t.id);
368
+ }
369
+ }
370
+ const allDescendants = new Map();
371
+ for (const t of tasks) {
372
+ const desc = new Set();
373
+ const queue = Array.from(leadsTo.get(t.id) || []);
374
+ while (queue.length > 0) {
375
+ const next = queue.shift();
376
+ if (!desc.has(next)) {
377
+ desc.add(next);
378
+ for (const child of leadsTo.get(next) || []) {
379
+ queue.push(child);
380
+ }
381
+ }
382
+ }
383
+ allDescendants.set(t.id, desc);
384
+ }
385
+ function hasOrder(idA, idB) {
386
+ return allDescendants.get(idA)?.has(idB) || allDescendants.get(idB)?.has(idA) || false;
387
+ }
388
+ for (const t of tasks) {
389
+ const access = t.access || "read_only";
390
+ const ownership = t.ownership || [];
391
+ if (access === "write" && ownership.length === 0) {
392
+ throw new Error(`Write task '${t.id}' must declare non-empty ownership paths`);
393
+ }
394
+ }
395
+ for (let i = 0; i < tasks.length; i++) {
396
+ for (let j = i + 1; j < tasks.length; j++) {
397
+ const taskA = tasks[i];
398
+ const taskB = tasks[j];
399
+ if (hasOrder(taskA.id, taskB.id)) {
400
+ continue;
401
+ }
402
+ const accessA = taskA.access || "read_only";
403
+ const accessB = taskB.access || "read_only";
404
+ if (accessA === "read_only" && accessB === "read_only") {
405
+ continue;
406
+ }
407
+ const pathsA = (taskA.ownership || []).map((p) => normalizeFilePath(p, cwd));
408
+ const pathsB = (taskB.ownership || []).map((p) => normalizeFilePath(p, cwd));
409
+ for (const pA of pathsA) {
410
+ for (const pB of pathsB) {
411
+ if (checkPathOverlap(pA, pB)) {
412
+ throw new Error(`Concurrent write ownership conflict between task '${taskA.id}' and task '${taskB.id}' on overlapping path ('${pA}' vs '${pB}'). Add depends_on to order them or separate ownership scopes.`);
413
+ }
414
+ }
415
+ }
416
+ }
417
+ }
418
+ }
419
+ const server = new McpServer({ name: "omp-worker", version: "1.0.0" }, {
420
+ instructions: "Use OMP Boss Mode by default for substantive tasks that require tool-driven investigation, implementation, workspace or environment changes, or other multi-step execution. Ordinary conversation, direct explanations, status questions, and answers that need no task execution remain with Codex; the user may also explicitly ask Codex to work directly. In Boss Mode, first perform bounded, risk-proportionate, read-only exploration of the goal and local context. Stop as soon as the evidence is decision-ready, tell the user the concise findings and proposed direction, and call omp_run_compact by default for single tasks or omp_run_batch_compact for multiple independent tasks. If omp_run_compact or omp_run_batch_compact returns a terminal compact result, perform decisive acceptance checks without requesting unnecessary full reports.",
421
+ });
422
+ server.registerTool("omp_run_batch_compact", {
423
+ title: "Run Batch OMP Tasks with DAG and Compact Aggregated Result (Preferred for Multi-task)",
424
+ description: "Submits a decomposed group of OMP tasks to execute concurrently in the server-side bounded rolling pool (max_parallel 1-10) using a detached group runner. Enforces dependency DAG and write-ownership safety, waits up to wait_seconds (0-240, default 60s) for batch completion, and returns stable compact aggregated results. If still running when deadline elapses, returns group_id and minimal progress counts for subsequent omp_wait_group.",
425
+ inputSchema: {
426
+ cwd: z.string().min(1).describe("Absolute working directory OMP may inspect and modify"),
427
+ tasks: z
428
+ .array(z.object({
429
+ id: z.string().min(1).max(100).describe("Unique task identifier within the group"),
430
+ goal: z.string().min(1).max(20_000).describe("Complete natural-language outcome OMP must deliver"),
431
+ acceptance: z.array(z.string().min(1).max(2_000)).max(20).default([]),
432
+ depends_on: z.array(z.string().min(1).max(100)).default([]),
433
+ access: z.enum(["read_only", "write"]).default("read_only"),
434
+ ownership: z
435
+ .array(z.string().min(1).max(1_000))
436
+ .default([])
437
+ .describe("Workspace paths this task is authorized to write to (required non-empty for write tasks)"),
438
+ timeout_minutes: z.number().int().min(1).max(120).optional(),
439
+ max_attempts: z.number().int().min(1).max(5).optional(),
440
+ }))
441
+ .min(1)
442
+ .max(50)
443
+ .describe("Array of decomposed tasks to execute concurrently according to dependency DAG and ownership safety"),
444
+ max_parallel: z
445
+ .number()
446
+ .int()
447
+ .min(1)
448
+ .max(10)
449
+ .default(4)
450
+ .describe("Maximum concurrent active OMP runners (1-10, default 4)"),
451
+ group_timeout_minutes: z
452
+ .number()
453
+ .int()
454
+ .min(1)
455
+ .max(120)
456
+ .default(60)
457
+ .describe("Hard overall group deadline in minutes (1-120, default 60)"),
458
+ default_timeout_minutes: z
459
+ .number()
460
+ .int()
461
+ .min(1)
462
+ .max(120)
463
+ .default(30)
464
+ .describe("Default per-task timeout in minutes if not specified in task (1-120, default 30)"),
465
+ default_max_attempts: z
466
+ .number()
467
+ .int()
468
+ .min(1)
469
+ .max(5)
470
+ .default(3)
471
+ .describe("Default per-task attempt limit if not specified in task (1-5, default 3)"),
472
+ wait_seconds: z
473
+ .number()
474
+ .int()
475
+ .min(0)
476
+ .max(240)
477
+ .default(60)
478
+ .describe("Maximum seconds to wait for batch completion inside this call (0-240, default 60)"),
479
+ supervisor_brief: z
480
+ .string()
481
+ .max(12_000)
482
+ .optional()
483
+ .describe("Shared decision-ready read-only findings, hypotheses, and constraints for the task group"),
484
+ },
485
+ outputSchema: batchStatusOutput,
486
+ annotations: {
487
+ readOnlyHint: false,
488
+ destructiveHint: true,
489
+ idempotentHint: false,
490
+ openWorldHint: true,
491
+ },
492
+ }, async ({ cwd, tasks, max_parallel, group_timeout_minutes, default_timeout_minutes, default_max_attempts, wait_seconds, supervisor_brief, }, extra) => {
493
+ const resolvedCwd = await validateWorkingDirectory(cwd);
494
+ validateDAGAndOwnership(tasks, resolvedCwd);
495
+ const groupId = createGroupId();
496
+ await ensureGroupDirectory(groupId);
497
+ const initialTaskRecords = tasks.map((t) => ({
498
+ id: t.id,
499
+ status: (t.depends_on && t.depends_on.length > 0 ? "pending" : "ready"),
500
+ goal: t.goal,
501
+ acceptance: t.acceptance || [],
502
+ dependsOn: t.depends_on || [],
503
+ access: t.access || "read_only",
504
+ ownership: t.ownership || [],
505
+ timeoutMinutes: t.timeout_minutes || default_timeout_minutes,
506
+ maxAttempts: t.max_attempts || default_max_attempts,
507
+ }));
508
+ const now = new Date().toISOString();
509
+ const groupRecord = {
510
+ version: 1,
511
+ id: groupId,
512
+ status: "queued",
513
+ cwd: resolvedCwd,
514
+ maxParallel: max_parallel,
515
+ groupTimeoutMinutes: group_timeout_minutes,
516
+ supervisorBrief: supervisor_brief,
517
+ createdAt: now,
518
+ updatedAt: now,
519
+ tasks: initialTaskRecords,
520
+ };
521
+ await writeGroup(groupRecord);
522
+ await launchGroupRunner(groupId);
523
+ const latestGroup = await waitForGroup(groupId, wait_seconds, extra?.signal);
524
+ const isTerminal = GROUP_TERMINAL_STATUSES.has(latestGroup.status);
525
+ const structuredContent = await buildCompactGroupResult(latestGroup, isTerminal, isTerminal
526
+ ? undefined
527
+ : `Batch execution ${groupId} is ${latestGroup.status} after ${wait_seconds}s. Use omp_wait_group to continue waiting.`);
528
+ return groupToolResult(structuredContent, isTerminal
529
+ ? undefined
530
+ : `Batch execution ${groupId} is ${latestGroup.status} after ${wait_seconds}s. Use omp_wait_group to continue waiting.`);
531
+ });
532
+ server.registerTool("omp_wait_group", {
533
+ title: "Wait for Batch Task Group",
534
+ description: "Wait for an existing OMP task group to reach a terminal status, polling up to wait_seconds (0-240, default 60s). If completed, returns full aggregated compact results in original input order. If still running when deadline elapses, returns minimal progress counts without leaking task summaries.",
535
+ inputSchema: {
536
+ group_id: z.string().min(1).describe("The unique group_id returned by omp_run_batch_compact"),
537
+ wait_seconds: z
538
+ .number()
539
+ .int()
540
+ .min(0)
541
+ .max(240)
542
+ .default(60)
543
+ .describe("Maximum seconds to wait inside this call (0-240, default 60)"),
544
+ },
545
+ outputSchema: batchStatusOutput,
546
+ annotations: {
547
+ readOnlyHint: true,
548
+ destructiveHint: false,
549
+ idempotentHint: true,
550
+ openWorldHint: false,
551
+ },
552
+ }, async ({ group_id, wait_seconds }, extra) => {
553
+ const latestGroup = await waitForGroup(group_id, wait_seconds, extra?.signal);
554
+ const isTerminal = GROUP_TERMINAL_STATUSES.has(latestGroup.status);
555
+ const structuredContent = await buildCompactGroupResult(latestGroup, isTerminal, isTerminal
556
+ ? undefined
557
+ : `Batch execution ${group_id} is ${latestGroup.status} after ${wait_seconds}s. Use omp_wait_group to continue waiting.`);
558
+ return groupToolResult(structuredContent, isTerminal
559
+ ? undefined
560
+ : `Batch execution ${group_id} is ${latestGroup.status} after ${wait_seconds}s. Use omp_wait_group to continue waiting.`);
561
+ });
562
+ server.registerTool("omp_cancel_group", {
563
+ title: "Cancel Batch Task Group",
564
+ description: "Request immediate cancellation of a running batch task group and its child tasks. Writes a cancellation request that the detached group coordinator handles safely. Call this immediately when the user asks to stop a batch.",
565
+ inputSchema: {
566
+ group_id: z.string().min(1).describe("The group_id of the batch task group to cancel"),
567
+ reason: z
568
+ .string()
569
+ .min(1)
570
+ .max(2_000)
571
+ .default("Cancelled at the user's request")
572
+ .describe("Reason for cancellation"),
573
+ },
574
+ outputSchema: batchStatusOutput,
575
+ annotations: {
576
+ readOnlyHint: false,
577
+ destructiveHint: true,
578
+ idempotentHint: true,
579
+ openWorldHint: false,
580
+ },
581
+ }, async ({ group_id, reason }) => {
582
+ let group = await readGroup(group_id);
583
+ if (GROUP_TERMINAL_STATUSES.has(group.status)) {
584
+ const structuredContent = await buildCompactGroupResult(group, true, `Batch group ${group_id} is already ${group.status}.`);
585
+ return groupToolResult(structuredContent, `Batch group ${group_id} is already ${group.status}.`);
586
+ }
587
+ const requestedAt = new Date().toISOString();
588
+ group.status = "cancelling";
589
+ group.cancelRequestedAt = requestedAt;
590
+ group.cancelReason = reason;
591
+ await writeGroup(group);
592
+ await writeGroupCancellationRequest(group_id, reason);
593
+ const structuredContent = await buildCompactGroupResult(group, false, `Cancellation requested for batch task group ${group_id}. Use omp_wait_group to wait for cancellation to finalize.`);
594
+ return groupToolResult(structuredContent, `Cancellation requested for batch task group ${group_id}. Use omp_wait_group to wait for cancellation to finalize.`);
595
+ });
596
+ server.registerTool("omp_run_compact", {
597
+ title: "Run OMP Task with Compact Result (Preferred for Single Task)",
598
+ description: "Preferred entrypoint for single substantive execution tasks. Creates a delegated OMP task, launches the detached runner, and waits up to wait_seconds (default 60s) for completion in a single MCP call. If finished, returns a compact summary, artifacts, verification, and details_path without dumping the full final response. If still running at the deadline, returns job_id and status running for subsequent omp_wait.",
599
+ inputSchema: {
600
+ goal: z.string().min(1).max(20_000).describe("Complete natural-language outcome OMP must deliver"),
601
+ cwd: z.string().min(1).describe("Absolute working directory OMP may inspect and modify"),
602
+ acceptance: z.array(z.string().min(1).max(2_000)).max(20).default([]),
603
+ supervisor_brief: z
604
+ .string()
605
+ .max(12_000)
606
+ .optional()
607
+ .describe("Decision-ready read-only findings, hypotheses, constraints, and recommended direction"),
608
+ timeout_minutes: z.number().int().min(1).max(120).default(30),
609
+ max_attempts: z.number().int().min(1).max(5).default(3),
610
+ wait_seconds: z
611
+ .number()
612
+ .int()
613
+ .min(0)
614
+ .max(60)
615
+ .default(60)
616
+ .describe("Maximum seconds to wait for terminal status inside this call (0-60, default 60)"),
617
+ },
618
+ outputSchema: statusOutput,
619
+ annotations: {
620
+ readOnlyHint: false,
621
+ destructiveHint: true,
622
+ idempotentHint: false,
623
+ openWorldHint: true,
624
+ },
625
+ }, async ({ goal, cwd, acceptance, supervisor_brief, timeout_minutes, max_attempts, wait_seconds }) => {
626
+ const { job: initialJob } = await createAndStartJob({
627
+ goal,
628
+ cwd,
629
+ acceptance,
630
+ supervisor_brief,
631
+ timeout_minutes,
632
+ max_attempts,
633
+ });
634
+ const deadline = Date.now() + wait_seconds * 1_000;
635
+ let job = initialJob;
636
+ while (!TERMINAL_STATUSES.has(job.status) && Date.now() < deadline) {
637
+ await sleep(Math.min(500, Math.max(1, deadline - Date.now())));
638
+ try {
639
+ job = await readJob(initialJob.id);
640
+ }
641
+ catch (error) {
642
+ if (Date.now() >= deadline) {
643
+ throw error;
644
+ }
645
+ }
646
+ }
647
+ if (TERMINAL_STATUSES.has(job.status)) {
648
+ return toolResult(job, `OMP task ${job.id} reached terminal status: ${job.status}.`);
649
+ }
650
+ return toolResult(job, `OMP task ${job.id} is still running after ${wait_seconds}s. Use omp_wait to continue waiting.`);
651
+ });
652
+ server.registerTool("omp_delegate", {
653
+ title: "Delegate Complete Task to OMP (Low-level)",
654
+ description: "Low-level background delegation entrypoint. Transfers ownership of an execution task to OMP and returns immediately with job_id. Prefer omp_run_compact for standard workflows to avoid separate delegate/wait round trips.",
655
+ inputSchema: {
656
+ goal: z.string().min(1).max(20_000).describe("Complete natural-language outcome OMP must deliver"),
657
+ cwd: z.string().min(1).describe("Absolute working directory OMP may inspect and modify"),
658
+ acceptance: z.array(z.string().min(1).max(2_000)).max(20).default([]),
659
+ supervisor_brief: z
660
+ .string()
661
+ .max(12_000)
662
+ .optional()
663
+ .describe("Decision-ready read-only findings, hypotheses, constraints, and recommended direction"),
664
+ timeout_minutes: z.number().int().min(1).max(120).default(30),
665
+ max_attempts: z.number().int().min(1).max(5).default(3),
666
+ },
667
+ outputSchema: statusOutput,
668
+ annotations: {
669
+ readOnlyHint: false,
670
+ destructiveHint: true,
671
+ idempotentHint: false,
672
+ openWorldHint: true,
673
+ },
674
+ }, async ({ goal, cwd, acceptance, supervisor_brief, timeout_minutes, max_attempts }) => {
675
+ const { job } = await createAndStartJob({
676
+ goal,
677
+ cwd,
678
+ acceptance,
679
+ supervisor_brief,
680
+ timeout_minutes,
681
+ max_attempts,
682
+ });
683
+ return toolResult(job, `OMP task accepted as ${job.id}. Use omp_wait to wait for completion.`);
684
+ });
685
+ server.registerTool("omp_cancel", {
686
+ title: "Stop OMP Task",
687
+ description: "Request immediate cancellation of one exact delegated OMP job. The detached runner owns the OMP child PID and terminates that process tree safely. Call this immediately when the user asks to stop; do not inspect PIDs or manually kill processes first.",
688
+ inputSchema: {
689
+ job_id: z.string().min(1),
690
+ reason: z.string().min(1).max(2_000).default("Cancelled at the user's request"),
691
+ },
692
+ outputSchema: statusOutput,
693
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
694
+ }, async ({ job_id, reason }) => {
695
+ let job = await readJob(job_id);
696
+ if (TERMINAL_STATUSES.has(job.status)) {
697
+ return toolResult(job, `OMP task ${job_id} is already ${job.status}.`);
698
+ }
699
+ const attempt = job.attempts.find((item) => item.number === job.currentAttempt);
700
+ if (!attempt)
701
+ throw new Error(`Attempt ${job.currentAttempt} is missing`);
702
+ const previousJobStatus = job.status;
703
+ const previousAttemptStatus = attempt.status;
704
+ const requestedAt = new Date().toISOString();
705
+ job.status = "cancelling";
706
+ job.cancelRequestedAt = requestedAt;
707
+ job.cancelReason = reason;
708
+ attempt.status = "cancelling";
709
+ attempt.cancelRequestedAt = requestedAt;
710
+ await writeJob(job);
711
+ try {
712
+ await writeCancellationRequest(job_id, reason);
713
+ }
714
+ catch (error) {
715
+ job = await readJob(job_id);
716
+ if (job.status === "cancelling") {
717
+ job.status = previousJobStatus;
718
+ const current = job.attempts.find((item) => item.number === job.currentAttempt);
719
+ if (current?.status === "cancelling")
720
+ current.status = previousAttemptStatus;
721
+ job.cancelRequestedAt = undefined;
722
+ job.cancelReason = undefined;
723
+ await writeJob(job);
724
+ }
725
+ throw error;
726
+ }
727
+ return toolResult(job, `Cancellation requested for OMP task ${job_id}.`);
728
+ });
729
+ server.registerTool("omp_wait", {
730
+ title: "Wait for OMP Task",
731
+ description: "Wait for an existing OMP job to reach a terminal status, polling up to wait_seconds (default 30s, max 60s). Returns the current status and summary once terminal or when the wait deadline elapses. Avoid polling in a tight loop.",
732
+ inputSchema: {
733
+ job_id: z.string().min(1),
734
+ wait_seconds: z.number().int().min(1).max(60).default(30),
735
+ },
736
+ outputSchema: statusOutput,
737
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
738
+ }, async ({ job_id, wait_seconds }) => {
739
+ const deadline = Date.now() + wait_seconds * 1_000;
740
+ let job = await readJob(job_id);
741
+ while (!TERMINAL_STATUSES.has(job.status) && Date.now() < deadline) {
742
+ await sleep(Math.min(500, Math.max(1, deadline - Date.now())));
743
+ try {
744
+ job = await readJob(job_id);
745
+ }
746
+ catch (error) {
747
+ if (Date.now() >= deadline)
748
+ throw error;
749
+ }
750
+ }
751
+ return toolResult(job);
752
+ });
753
+ server.registerTool("omp_result", {
754
+ title: "Inspect Complete OMP Task Result",
755
+ description: "Retrieve full details of a terminal OMP job, including finalResponse and attempt logs. Call this only when minimal acceptance check fails, high-risk operations occurred, or the user explicitly asks for full inspection; do not call this after every successful compact run.",
756
+ inputSchema: { job_id: z.string().min(1) },
757
+ outputSchema: {
758
+ job_id: z.string(),
759
+ status: z.string(),
760
+ attempt: z.number().int(),
761
+ max_attempts: z.number().int(),
762
+ session_id: z.string().optional(),
763
+ goal: z.string(),
764
+ cwd: z.string(),
765
+ summary: z.string().optional(),
766
+ artifacts: z.array(artifactSchema),
767
+ verification: z.array(z.string()),
768
+ remaining: z.array(z.string()),
769
+ final_response: z.string().optional(),
770
+ error: z.string().optional(),
771
+ details_path: z.string(),
772
+ attempts: z.array(z.object({
773
+ number: z.number().int(),
774
+ kind: z.string(),
775
+ status: z.string(),
776
+ stdout_path: z.string(),
777
+ stderr_path: z.string(),
778
+ exit_code: z.number().int().nullable().optional(),
779
+ error: z.string().optional(),
780
+ })),
781
+ },
782
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
783
+ }, async ({ job_id }) => {
784
+ const job = await readJob(job_id);
785
+ const structuredContent = {
786
+ job_id: job.id,
787
+ status: job.status,
788
+ attempt: job.currentAttempt,
789
+ max_attempts: job.maxAttempts,
790
+ session_id: job.sessionId,
791
+ goal: job.goal,
792
+ cwd: job.cwd,
793
+ summary: job.summary,
794
+ artifacts: job.artifacts,
795
+ verification: job.verification,
796
+ remaining: job.remaining,
797
+ final_response: job.finalResponse,
798
+ error: job.error,
799
+ details_path: jobFilePath(job.id),
800
+ attempts: job.attempts.map((attempt) => ({
801
+ number: attempt.number,
802
+ kind: attempt.kind,
803
+ status: attempt.status,
804
+ stdout_path: attempt.stdoutPath,
805
+ stderr_path: attempt.stderrPath,
806
+ exit_code: attempt.exitCode ?? null,
807
+ error: attempt.error,
808
+ })),
809
+ };
810
+ return {
811
+ content: [{ type: "text", text: JSON.stringify(structuredContent, null, 2) }],
812
+ structuredContent,
813
+ };
814
+ });
815
+ server.registerTool("omp_continue", {
816
+ title: "Send Supervisory Feedback to Same OMP Session",
817
+ description: "Resume the same OMP session with targeted supervisory feedback to correct specific acceptance defects without starting a fresh task from scratch. Bounded to remaining attempts within max_attempts.",
818
+ inputSchema: {
819
+ job_id: z.string().min(1),
820
+ feedback: z
821
+ .string()
822
+ .min(1)
823
+ .max(12_000)
824
+ .describe("Specific defect, evidence, intended correction, and success check"),
825
+ timeout_minutes: z.number().int().min(1).max(120).default(30),
826
+ },
827
+ outputSchema: statusOutput,
828
+ annotations: {
829
+ readOnlyHint: false,
830
+ destructiveHint: true,
831
+ idempotentHint: false,
832
+ openWorldHint: true,
833
+ },
834
+ }, async ({ job_id, feedback, timeout_minutes }) => {
835
+ const job = await readJob(job_id);
836
+ if (!job.sessionId) {
837
+ throw new Error(`Job ${job_id} does not have a recorded sessionId to continue`);
838
+ }
839
+ if (job.currentAttempt >= job.maxAttempts) {
840
+ throw new Error(`Job ${job_id} has reached its attempt budget (${job.maxAttempts})`);
841
+ }
842
+ const nextNumber = job.currentAttempt + 1;
843
+ const directory = await ensureJobDirectory(job.id);
844
+ const promptPath = await writePrompt(job.id, nextNumber, buildContinuePrompt(job, feedback));
845
+ const paths = attemptPaths(directory, nextNumber);
846
+ const attempt = {
847
+ number: nextNumber,
848
+ kind: "continue",
849
+ status: "queued",
850
+ promptPath,
851
+ timeoutMinutes: timeout_minutes,
852
+ feedback,
853
+ ...paths,
854
+ };
855
+ job.currentAttempt = nextNumber;
856
+ job.status = "queued";
857
+ job.error = undefined;
858
+ job.attempts.push(attempt);
859
+ await clearCancellationRequest(job.id);
860
+ await writeJob(job);
861
+ try {
862
+ await launchRunner(job);
863
+ }
864
+ catch (error) {
865
+ job.status = "failed";
866
+ job.error = error instanceof Error ? error.message : String(error);
867
+ attempt.status = "failed";
868
+ attempt.error = job.error;
869
+ await writeJob(job);
870
+ throw error;
871
+ }
872
+ return toolResult(job, `Supervisory feedback dispatched to OMP session ${job.sessionId} as attempt ${nextNumber}.`);
873
+ });
874
+ if (process.env.OMP_WORKER_AUTO_CLEANUP_ON_START === "true" || process.env.OMP_WORKER_AUTO_CLEANUP_ON_START === "1") {
875
+ const envOpts = getRetentionOptionsFromEnv();
876
+ if (envOpts.ttlSeconds || envOpts.maxBytes) {
877
+ cleanState(envOpts)
878
+ .then((res) => {
879
+ if (res.errors.length > 0) {
880
+ for (const err of res.errors) {
881
+ process.stderr.write(`[state-cleanup warning] ${err.path}: ${err.error}\n`);
882
+ }
883
+ }
884
+ })
885
+ .catch((err) => {
886
+ process.stderr.write(`[state-cleanup startup error] ${err instanceof Error ? err.message : String(err)}\n`);
887
+ });
888
+ }
889
+ }
890
+ const transport = new StdioServerTransport();
891
+ await server.connect(transport);
892
+ //# sourceMappingURL=index.js.map