omnilane 0.8.2 → 0.9.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/CHANGELOG.md +58 -1
- package/README.ja.md +75 -14
- package/README.ko.md +71 -13
- package/README.md +84 -15
- package/README.zh-CN.md +59 -11
- package/README.zh-TW.md +73 -11
- package/VERSION +1 -1
- package/bin/omnilane +12 -3
- package/bin/omnilane-mcp +756 -0
- package/completions/omnilane.fish +50 -0
- package/package.json +1 -1
- package/scripts/check.sh +125 -0
- package/scripts/configure.sh +149 -1
- package/scripts/dispatch.sh +4 -4
- package/scripts/doctor.sh +38 -0
- package/scripts/jobs.sh +132 -10
- package/scripts/lib/common.sh +57 -3
- package/scripts/runners/run-cerebras.sh +7 -0
- package/scripts/runners/run-deepseek.sh +7 -0
- package/scripts/runners/run-groq.sh +7 -0
- package/scripts/runners/run-mistral.sh +7 -0
- package/scripts/runners/run-openai-compat.sh +102 -0
- package/scripts/runners/run-openrouter.sh +5 -84
- package/scripts/runners/run-zai.sh +7 -0
package/bin/omnilane-mcp
ADDED
|
@@ -0,0 +1,756 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const readline = require('readline');
|
|
7
|
+
const { spawn } = require('child_process');
|
|
8
|
+
|
|
9
|
+
const ROOT = path.resolve(__dirname, '..');
|
|
10
|
+
const DISPATCH_SCRIPT = path.join(ROOT, 'scripts', 'dispatch.sh');
|
|
11
|
+
const JOBS_SCRIPT = path.join(ROOT, 'scripts', 'jobs.sh');
|
|
12
|
+
const DOCTOR_SCRIPT = path.join(ROOT, 'scripts', 'doctor.sh');
|
|
13
|
+
const LATEST_PROTOCOL_VERSION = '2025-11-25';
|
|
14
|
+
const SUPPORTED_PROTOCOL_VERSIONS = new Set([
|
|
15
|
+
'2025-11-25',
|
|
16
|
+
'2025-06-18',
|
|
17
|
+
'2025-03-26',
|
|
18
|
+
'2024-11-05',
|
|
19
|
+
]);
|
|
20
|
+
const MAX_COLLECTED_BYTES = 1024 * 1024;
|
|
21
|
+
const TRUNCATION_MARKER = `[omnilane-mcp: output truncated after ${MAX_COLLECTED_BYTES} bytes]`;
|
|
22
|
+
const LANE_PATTERN = /^[a-z][a-z0-9-]*$/;
|
|
23
|
+
const JOB_ID_PATTERN = /^[0-9]{8}-[0-9]{6}-[0-9]+-[0-9]+$/;
|
|
24
|
+
const OWN = (value, key) => Object.prototype.hasOwnProperty.call(value, key);
|
|
25
|
+
|
|
26
|
+
let serverVersion;
|
|
27
|
+
try {
|
|
28
|
+
serverVersion = fs.readFileSync(path.join(ROOT, 'VERSION'), 'utf8').trim();
|
|
29
|
+
if (!/^[0-9]+\.[0-9]+\.[0-9]+$/.test(serverVersion)) {
|
|
30
|
+
throw new Error('invalid VERSION file');
|
|
31
|
+
}
|
|
32
|
+
} catch (error) {
|
|
33
|
+
process.stderr.write(`omnilane-mcp: ${error.message}\n`);
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const tools = [
|
|
38
|
+
{
|
|
39
|
+
name: 'route',
|
|
40
|
+
description: 'Route a task through omnilane. Defaults to read-only advise mode; work mode requires an explicit workdir.',
|
|
41
|
+
inputSchema: {
|
|
42
|
+
type: 'object',
|
|
43
|
+
properties: {
|
|
44
|
+
lane: {
|
|
45
|
+
type: 'string',
|
|
46
|
+
pattern: '^[a-z][a-z0-9-]*$',
|
|
47
|
+
description: 'Configured omnilane lane name.',
|
|
48
|
+
},
|
|
49
|
+
task: { type: 'string', description: 'Task text sent to the selected lane.' },
|
|
50
|
+
mode: {
|
|
51
|
+
type: 'string',
|
|
52
|
+
enum: ['advise', 'work'],
|
|
53
|
+
default: 'advise',
|
|
54
|
+
description: 'Read-only advice or write-enabled work.',
|
|
55
|
+
},
|
|
56
|
+
workdir: {
|
|
57
|
+
type: 'string',
|
|
58
|
+
minLength: 1,
|
|
59
|
+
description: 'Working directory. Required when mode is work.',
|
|
60
|
+
},
|
|
61
|
+
vendor: { type: 'string', description: 'Optional configured vendor override.' },
|
|
62
|
+
model: { type: 'string', description: 'Optional routed model override.' },
|
|
63
|
+
effort: { type: 'string', description: 'Optional routed effort override.' },
|
|
64
|
+
background: {
|
|
65
|
+
type: 'boolean',
|
|
66
|
+
default: false,
|
|
67
|
+
description: 'Run as an omnilane background job and return its job ID.',
|
|
68
|
+
},
|
|
69
|
+
timeout: {
|
|
70
|
+
type: 'integer',
|
|
71
|
+
minimum: 1,
|
|
72
|
+
description: 'Per-call timeout in seconds forwarded to dispatch.sh.',
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
required: ['lane', 'task'],
|
|
76
|
+
additionalProperties: false,
|
|
77
|
+
allOf: [
|
|
78
|
+
{
|
|
79
|
+
if: { properties: { mode: { const: 'work' } }, required: ['mode'] },
|
|
80
|
+
then: { required: ['workdir'] },
|
|
81
|
+
},
|
|
82
|
+
],
|
|
83
|
+
},
|
|
84
|
+
annotations: {
|
|
85
|
+
readOnlyHint: false,
|
|
86
|
+
destructiveHint: true,
|
|
87
|
+
idempotentHint: false,
|
|
88
|
+
openWorldHint: true,
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
name: 'jobs_status',
|
|
93
|
+
description: 'Read the current status of one omnilane background job.',
|
|
94
|
+
inputSchema: {
|
|
95
|
+
type: 'object',
|
|
96
|
+
properties: {
|
|
97
|
+
id: {
|
|
98
|
+
type: 'string',
|
|
99
|
+
pattern: '^[0-9]{8}-[0-9]{6}-[0-9]+-[0-9]+$',
|
|
100
|
+
description: 'Omnilane background job ID.',
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
required: ['id'],
|
|
104
|
+
additionalProperties: false,
|
|
105
|
+
},
|
|
106
|
+
annotations: {
|
|
107
|
+
readOnlyHint: true,
|
|
108
|
+
destructiveHint: false,
|
|
109
|
+
idempotentHint: true,
|
|
110
|
+
openWorldHint: false,
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
name: 'jobs_result',
|
|
115
|
+
description: 'Read the result of one completed omnilane background job.',
|
|
116
|
+
inputSchema: {
|
|
117
|
+
type: 'object',
|
|
118
|
+
properties: {
|
|
119
|
+
id: {
|
|
120
|
+
type: 'string',
|
|
121
|
+
pattern: '^[0-9]{8}-[0-9]{6}-[0-9]+-[0-9]+$',
|
|
122
|
+
description: 'Omnilane background job ID.',
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
required: ['id'],
|
|
126
|
+
additionalProperties: false,
|
|
127
|
+
},
|
|
128
|
+
annotations: {
|
|
129
|
+
readOnlyHint: true,
|
|
130
|
+
destructiveHint: false,
|
|
131
|
+
idempotentHint: true,
|
|
132
|
+
openWorldHint: false,
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
name: 'list_lanes',
|
|
137
|
+
description: 'List the effective omnilane routing table and available fallback selection.',
|
|
138
|
+
inputSchema: {
|
|
139
|
+
type: 'object',
|
|
140
|
+
properties: {},
|
|
141
|
+
additionalProperties: false,
|
|
142
|
+
},
|
|
143
|
+
annotations: {
|
|
144
|
+
readOnlyHint: true,
|
|
145
|
+
destructiveHint: false,
|
|
146
|
+
idempotentHint: true,
|
|
147
|
+
openWorldHint: false,
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
name: 'explain',
|
|
152
|
+
description: 'Explain one lane offline: every fallback candidate and the selected vendor, with no provider call and no job state.',
|
|
153
|
+
inputSchema: {
|
|
154
|
+
type: 'object',
|
|
155
|
+
properties: {
|
|
156
|
+
lane: {
|
|
157
|
+
type: 'string',
|
|
158
|
+
pattern: '^[a-z][a-z0-9-]*$',
|
|
159
|
+
description: 'Configured omnilane lane name.',
|
|
160
|
+
},
|
|
161
|
+
json: {
|
|
162
|
+
type: 'boolean',
|
|
163
|
+
default: false,
|
|
164
|
+
description: 'Return the versioned JSON envelope instead of the human table.',
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
required: ['lane'],
|
|
168
|
+
additionalProperties: false,
|
|
169
|
+
},
|
|
170
|
+
annotations: {
|
|
171
|
+
readOnlyHint: true,
|
|
172
|
+
destructiveHint: false,
|
|
173
|
+
idempotentHint: true,
|
|
174
|
+
openWorldHint: false,
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
name: 'validate',
|
|
179
|
+
description: 'Lint the effective routing table offline and report PASS/WARN/FAIL per lane, with no provider call and no job state.',
|
|
180
|
+
inputSchema: {
|
|
181
|
+
type: 'object',
|
|
182
|
+
properties: {
|
|
183
|
+
json: {
|
|
184
|
+
type: 'boolean',
|
|
185
|
+
default: false,
|
|
186
|
+
description: 'Return the versioned JSON envelope instead of the human report.',
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
additionalProperties: false,
|
|
190
|
+
},
|
|
191
|
+
annotations: {
|
|
192
|
+
readOnlyHint: true,
|
|
193
|
+
destructiveHint: false,
|
|
194
|
+
idempotentHint: true,
|
|
195
|
+
openWorldHint: false,
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
{
|
|
199
|
+
name: 'dry_run',
|
|
200
|
+
description: 'Resolve a dispatch plan (vendor, model, mode, timeouts, side-effect decision) without calling any provider or creating job state. Work mode requires an explicit workdir.',
|
|
201
|
+
inputSchema: {
|
|
202
|
+
type: 'object',
|
|
203
|
+
properties: {
|
|
204
|
+
lane: {
|
|
205
|
+
type: 'string',
|
|
206
|
+
pattern: '^[a-z][a-z0-9-]*$',
|
|
207
|
+
description: 'Configured omnilane lane name.',
|
|
208
|
+
},
|
|
209
|
+
task: { type: 'string', description: 'Task text used only to resolve the plan; it is never sent to a provider.' },
|
|
210
|
+
mode: {
|
|
211
|
+
type: 'string',
|
|
212
|
+
enum: ['advise', 'work'],
|
|
213
|
+
default: 'advise',
|
|
214
|
+
description: 'Read-only advice or write-enabled work.',
|
|
215
|
+
},
|
|
216
|
+
workdir: {
|
|
217
|
+
type: 'string',
|
|
218
|
+
minLength: 1,
|
|
219
|
+
description: 'Working directory. Required when mode is work.',
|
|
220
|
+
},
|
|
221
|
+
vendor: { type: 'string', description: 'Optional configured vendor override.' },
|
|
222
|
+
model: { type: 'string', description: 'Optional routed model override.' },
|
|
223
|
+
effort: { type: 'string', description: 'Optional routed effort override.' },
|
|
224
|
+
timeout: {
|
|
225
|
+
type: 'integer',
|
|
226
|
+
minimum: 1,
|
|
227
|
+
description: 'Per-call timeout in seconds forwarded to dispatch.sh.',
|
|
228
|
+
},
|
|
229
|
+
},
|
|
230
|
+
required: ['lane', 'task'],
|
|
231
|
+
additionalProperties: false,
|
|
232
|
+
allOf: [
|
|
233
|
+
{
|
|
234
|
+
if: { properties: { mode: { const: 'work' } }, required: ['mode'] },
|
|
235
|
+
then: { required: ['workdir'] },
|
|
236
|
+
},
|
|
237
|
+
],
|
|
238
|
+
},
|
|
239
|
+
annotations: {
|
|
240
|
+
readOnlyHint: true,
|
|
241
|
+
destructiveHint: false,
|
|
242
|
+
idempotentHint: true,
|
|
243
|
+
openWorldHint: false,
|
|
244
|
+
},
|
|
245
|
+
},
|
|
246
|
+
{
|
|
247
|
+
name: 'jobs_list',
|
|
248
|
+
description: 'List omnilane background jobs. Metadata only; task and result bodies stay private.',
|
|
249
|
+
inputSchema: {
|
|
250
|
+
type: 'object',
|
|
251
|
+
properties: {
|
|
252
|
+
json: {
|
|
253
|
+
type: 'boolean',
|
|
254
|
+
default: false,
|
|
255
|
+
description: 'Return the versioned JSON envelope instead of the human list.',
|
|
256
|
+
},
|
|
257
|
+
},
|
|
258
|
+
additionalProperties: false,
|
|
259
|
+
},
|
|
260
|
+
annotations: {
|
|
261
|
+
readOnlyHint: true,
|
|
262
|
+
destructiveHint: false,
|
|
263
|
+
idempotentHint: true,
|
|
264
|
+
openWorldHint: false,
|
|
265
|
+
},
|
|
266
|
+
},
|
|
267
|
+
{
|
|
268
|
+
name: 'doctor',
|
|
269
|
+
description: 'Read-only local health report for routing, state, watchdog, and optional UI support.',
|
|
270
|
+
inputSchema: {
|
|
271
|
+
type: 'object',
|
|
272
|
+
properties: {
|
|
273
|
+
json: {
|
|
274
|
+
type: 'boolean',
|
|
275
|
+
default: false,
|
|
276
|
+
description: 'Return the versioned JSON envelope instead of the human report.',
|
|
277
|
+
},
|
|
278
|
+
},
|
|
279
|
+
additionalProperties: false,
|
|
280
|
+
},
|
|
281
|
+
annotations: {
|
|
282
|
+
readOnlyHint: true,
|
|
283
|
+
destructiveHint: false,
|
|
284
|
+
idempotentHint: true,
|
|
285
|
+
openWorldHint: false,
|
|
286
|
+
},
|
|
287
|
+
},
|
|
288
|
+
{
|
|
289
|
+
name: 'jobs_stats',
|
|
290
|
+
description: 'Aggregate local background-job outcomes and lane/vendor distribution from bounded public metadata. Never reads task or result bodies.',
|
|
291
|
+
inputSchema: {
|
|
292
|
+
type: 'object',
|
|
293
|
+
properties: {
|
|
294
|
+
last: {
|
|
295
|
+
type: 'integer',
|
|
296
|
+
minimum: 1,
|
|
297
|
+
description: 'Limit the aggregate to the most recent N jobs.',
|
|
298
|
+
},
|
|
299
|
+
json: {
|
|
300
|
+
type: 'boolean',
|
|
301
|
+
default: false,
|
|
302
|
+
description: 'Return the versioned JSON envelope instead of the human report.',
|
|
303
|
+
},
|
|
304
|
+
},
|
|
305
|
+
additionalProperties: false,
|
|
306
|
+
},
|
|
307
|
+
annotations: {
|
|
308
|
+
readOnlyHint: true,
|
|
309
|
+
destructiveHint: false,
|
|
310
|
+
idempotentHint: true,
|
|
311
|
+
openWorldHint: false,
|
|
312
|
+
},
|
|
313
|
+
},
|
|
314
|
+
{
|
|
315
|
+
name: 'jobs_audit',
|
|
316
|
+
description: 'Bounded read-only integrity and privacy scan of the local job store. Reports findings without printing task or result content.',
|
|
317
|
+
inputSchema: {
|
|
318
|
+
type: 'object',
|
|
319
|
+
properties: {
|
|
320
|
+
last: {
|
|
321
|
+
type: 'integer',
|
|
322
|
+
minimum: 1,
|
|
323
|
+
description: 'Limit the scan to the most recent N jobs.',
|
|
324
|
+
},
|
|
325
|
+
json: {
|
|
326
|
+
type: 'boolean',
|
|
327
|
+
default: false,
|
|
328
|
+
description: 'Return the versioned JSON envelope instead of the human report.',
|
|
329
|
+
},
|
|
330
|
+
},
|
|
331
|
+
additionalProperties: false,
|
|
332
|
+
},
|
|
333
|
+
annotations: {
|
|
334
|
+
readOnlyHint: true,
|
|
335
|
+
destructiveHint: false,
|
|
336
|
+
idempotentHint: true,
|
|
337
|
+
openWorldHint: false,
|
|
338
|
+
},
|
|
339
|
+
},
|
|
340
|
+
];
|
|
341
|
+
|
|
342
|
+
class RpcError extends Error {
|
|
343
|
+
constructor(code, message, data) {
|
|
344
|
+
super(message);
|
|
345
|
+
this.code = code;
|
|
346
|
+
this.data = data;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const activeChildren = new Set();
|
|
351
|
+
let shuttingDown = false;
|
|
352
|
+
let outputClosed = false;
|
|
353
|
+
|
|
354
|
+
function isObject(value) {
|
|
355
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function textResult(text) {
|
|
359
|
+
return { content: [{ type: 'text', text }] };
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function toolError(message) {
|
|
363
|
+
return {
|
|
364
|
+
content: [{ type: 'text', text: `Error: ${message}` }],
|
|
365
|
+
isError: true,
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function writeMessage(message) {
|
|
370
|
+
if (outputClosed) return;
|
|
371
|
+
process.stdout.write(`${JSON.stringify(message)}\n`);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function writeResult(id, result) {
|
|
375
|
+
writeMessage({ jsonrpc: '2.0', id, result });
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function writeError(id, code, message, data) {
|
|
379
|
+
const error = { code, message };
|
|
380
|
+
if (data !== undefined) error.data = data;
|
|
381
|
+
writeMessage({ jsonrpc: '2.0', id, error });
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function validationError(args, allowedKeys) {
|
|
385
|
+
if (!isObject(args)) return 'arguments must be an object';
|
|
386
|
+
const unexpected = Object.keys(args).filter((key) => !allowedKeys.includes(key));
|
|
387
|
+
if (unexpected.length > 0) return `unexpected argument: ${unexpected[0]}`;
|
|
388
|
+
return null;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function validateRouteArguments(args) {
|
|
392
|
+
let error = validationError(args, [
|
|
393
|
+
'lane', 'task', 'mode', 'workdir', 'vendor', 'model', 'effort',
|
|
394
|
+
'background', 'timeout',
|
|
395
|
+
]);
|
|
396
|
+
if (error) return error;
|
|
397
|
+
if (typeof args.lane !== 'string' || !LANE_PATTERN.test(args.lane)) {
|
|
398
|
+
return 'lane must match ^[a-z][a-z0-9-]*$';
|
|
399
|
+
}
|
|
400
|
+
if (typeof args.task !== 'string') return 'task must be a string';
|
|
401
|
+
if (OWN(args, 'mode') && args.mode !== 'advise' && args.mode !== 'work') {
|
|
402
|
+
return 'mode must be advise or work';
|
|
403
|
+
}
|
|
404
|
+
if (OWN(args, 'workdir') && (typeof args.workdir !== 'string' || args.workdir.length === 0)) {
|
|
405
|
+
return 'workdir must be a non-empty string';
|
|
406
|
+
}
|
|
407
|
+
for (const key of ['vendor', 'model', 'effort']) {
|
|
408
|
+
if (OWN(args, key) && typeof args[key] !== 'string') return `${key} must be a string`;
|
|
409
|
+
}
|
|
410
|
+
if (OWN(args, 'background') && typeof args.background !== 'boolean') {
|
|
411
|
+
return 'background must be a boolean';
|
|
412
|
+
}
|
|
413
|
+
if (OWN(args, 'timeout') && (!Number.isInteger(args.timeout) || args.timeout < 1)) {
|
|
414
|
+
return 'timeout must be a positive integer';
|
|
415
|
+
}
|
|
416
|
+
if (args.mode === 'work' && !OWN(args, 'workdir')) {
|
|
417
|
+
return 'work mode requires an explicit workdir';
|
|
418
|
+
}
|
|
419
|
+
return null;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function validateJobArguments(args) {
|
|
423
|
+
const error = validationError(args, ['id']);
|
|
424
|
+
if (error) return error;
|
|
425
|
+
if (typeof args.id !== 'string' || !JOB_ID_PATTERN.test(args.id)) {
|
|
426
|
+
return 'id must match ^[0-9]{8}-[0-9]{6}-[0-9]+-[0-9]+$';
|
|
427
|
+
}
|
|
428
|
+
return null;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function validateJsonOnlyArguments(args) {
|
|
432
|
+
const error = validationError(args, ['json']);
|
|
433
|
+
if (error) return error;
|
|
434
|
+
if (OWN(args, 'json') && typeof args.json !== 'boolean') return 'json must be a boolean';
|
|
435
|
+
return null;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function validateJobsQueryArguments(args) {
|
|
439
|
+
const error = validationError(args, ['last', 'json']);
|
|
440
|
+
if (error) return error;
|
|
441
|
+
if (OWN(args, 'last') && (!Number.isInteger(args.last) || args.last < 1)) {
|
|
442
|
+
return 'last must be a positive integer';
|
|
443
|
+
}
|
|
444
|
+
if (OWN(args, 'json') && typeof args.json !== 'boolean') return 'json must be a boolean';
|
|
445
|
+
return null;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function validateExplainArguments(args) {
|
|
449
|
+
const error = validationError(args, ['lane', 'json']);
|
|
450
|
+
if (error) return error;
|
|
451
|
+
if (typeof args.lane !== 'string' || !LANE_PATTERN.test(args.lane)) {
|
|
452
|
+
return 'lane must match ^[a-z][a-z0-9-]*$';
|
|
453
|
+
}
|
|
454
|
+
if (OWN(args, 'json') && typeof args.json !== 'boolean') return 'json must be a boolean';
|
|
455
|
+
return null;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function validateDryRunArguments(args) {
|
|
459
|
+
const error = validationError(args, [
|
|
460
|
+
'lane', 'task', 'mode', 'workdir', 'vendor', 'model', 'effort', 'timeout',
|
|
461
|
+
]);
|
|
462
|
+
if (error) return error;
|
|
463
|
+
if (typeof args.lane !== 'string' || !LANE_PATTERN.test(args.lane)) {
|
|
464
|
+
return 'lane must match ^[a-z][a-z0-9-]*$';
|
|
465
|
+
}
|
|
466
|
+
if (typeof args.task !== 'string') return 'task must be a string';
|
|
467
|
+
if (OWN(args, 'mode') && args.mode !== 'advise' && args.mode !== 'work') {
|
|
468
|
+
return 'mode must be advise or work';
|
|
469
|
+
}
|
|
470
|
+
if (OWN(args, 'workdir') && (typeof args.workdir !== 'string' || args.workdir.length === 0)) {
|
|
471
|
+
return 'workdir must be a non-empty string';
|
|
472
|
+
}
|
|
473
|
+
for (const key of ['vendor', 'model', 'effort']) {
|
|
474
|
+
if (OWN(args, key) && typeof args[key] !== 'string') return `${key} must be a string`;
|
|
475
|
+
}
|
|
476
|
+
if (OWN(args, 'timeout') && (!Number.isInteger(args.timeout) || args.timeout < 1)) {
|
|
477
|
+
return 'timeout must be a positive integer';
|
|
478
|
+
}
|
|
479
|
+
if (args.mode === 'work' && !OWN(args, 'workdir')) {
|
|
480
|
+
return 'work mode requires an explicit workdir';
|
|
481
|
+
}
|
|
482
|
+
return null;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function createCapture() {
|
|
486
|
+
const chunks = { stdout: [], stderr: [] };
|
|
487
|
+
let collected = 0;
|
|
488
|
+
let truncated = false;
|
|
489
|
+
|
|
490
|
+
function add(stream, chunk) {
|
|
491
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
492
|
+
const remaining = MAX_COLLECTED_BYTES - collected;
|
|
493
|
+
if (remaining > 0) {
|
|
494
|
+
chunks[stream].push(buffer.subarray(0, remaining));
|
|
495
|
+
collected += Math.min(buffer.length, remaining);
|
|
496
|
+
}
|
|
497
|
+
if (buffer.length > remaining) truncated = true;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function read(stream) {
|
|
501
|
+
return Buffer.concat(chunks[stream]).toString('utf8');
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
return {
|
|
505
|
+
add,
|
|
506
|
+
stdout: () => read('stdout'),
|
|
507
|
+
stderr: () => read('stderr'),
|
|
508
|
+
wasTruncated: () => truncated,
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function appendTruncationMarker(text, truncated) {
|
|
513
|
+
if (!truncated) return text;
|
|
514
|
+
const separator = text.length === 0 || text.endsWith('\n') ? '' : '\n';
|
|
515
|
+
return `${text}${separator}${TRUNCATION_MARKER}`;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function runScript(script, args) {
|
|
519
|
+
return new Promise((resolve) => {
|
|
520
|
+
const capture = createCapture();
|
|
521
|
+
let child;
|
|
522
|
+
let settled = false;
|
|
523
|
+
|
|
524
|
+
try {
|
|
525
|
+
child = spawn('bash', [script].concat(args), {
|
|
526
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
527
|
+
});
|
|
528
|
+
} catch (error) {
|
|
529
|
+
resolve({ spawnError: error });
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
activeChildren.add(child);
|
|
534
|
+
child.stdout.on('data', (chunk) => capture.add('stdout', chunk));
|
|
535
|
+
child.stderr.on('data', (chunk) => capture.add('stderr', chunk));
|
|
536
|
+
|
|
537
|
+
function finish(result) {
|
|
538
|
+
if (settled) return;
|
|
539
|
+
settled = true;
|
|
540
|
+
activeChildren.delete(child);
|
|
541
|
+
resolve(Object.assign(result, {
|
|
542
|
+
stdout: capture.stdout(),
|
|
543
|
+
stderr: capture.stderr(),
|
|
544
|
+
truncated: capture.wasTruncated(),
|
|
545
|
+
}));
|
|
546
|
+
if (shuttingDown && activeChildren.size === 0) {
|
|
547
|
+
setImmediate(() => process.exit(0));
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
child.once('error', (error) => finish({ spawnError: error }));
|
|
552
|
+
child.once('close', (code, signal) => finish({ code, signal }));
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function processFailure(label, result) {
|
|
557
|
+
if (result.spawnError) {
|
|
558
|
+
return toolError(`${label} could not start: ${result.spawnError.message}`);
|
|
559
|
+
}
|
|
560
|
+
const parts = [];
|
|
561
|
+
if (result.stderr) parts.push(result.stderr);
|
|
562
|
+
if (result.stdout) parts.push(result.stdout);
|
|
563
|
+
let detail = parts.join(parts.length > 1 ? '\n' : '');
|
|
564
|
+
detail = appendTruncationMarker(detail, result.truncated);
|
|
565
|
+
const status = result.signal ? `signal ${result.signal}` : `exit ${result.code}`;
|
|
566
|
+
return toolError(`${label} failed with ${status}${detail ? `:\n${detail}` : ''}`);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
async function runToolScript(label, script, args) {
|
|
570
|
+
const result = await runScript(script, args);
|
|
571
|
+
if (result.spawnError || result.code !== 0) return processFailure(label, result);
|
|
572
|
+
return textResult(appendTruncationMarker(result.stdout, result.truncated));
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
async function callRoute(args) {
|
|
576
|
+
const error = validateRouteArguments(args);
|
|
577
|
+
if (error) return toolError(error);
|
|
578
|
+
|
|
579
|
+
const mode = OWN(args, 'mode') ? args.mode : 'advise';
|
|
580
|
+
const argv = ['--mode', mode];
|
|
581
|
+
for (const key of ['workdir', 'vendor', 'model', 'effort', 'timeout']) {
|
|
582
|
+
if (OWN(args, key)) argv.push(`--${key}`, String(args[key]));
|
|
583
|
+
}
|
|
584
|
+
if (args.background === true) argv.push('--background');
|
|
585
|
+
argv.push(args.lane, args.task);
|
|
586
|
+
|
|
587
|
+
const result = await runScript(DISPATCH_SCRIPT, argv);
|
|
588
|
+
if (result.spawnError || result.code !== 0) return processFailure('route', result);
|
|
589
|
+
const stdout = appendTruncationMarker(result.stdout, result.truncated);
|
|
590
|
+
if (args.background === true) {
|
|
591
|
+
const jobId = stdout.split(/\r?\n/).find((line) => JOB_ID_PATTERN.test(line));
|
|
592
|
+
if (!jobId) return toolError('background route did not return a valid job ID');
|
|
593
|
+
return textResult(jobId);
|
|
594
|
+
}
|
|
595
|
+
return textResult(stdout);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
async function callTool(name, args) {
|
|
599
|
+
if (name === 'route') return callRoute(args);
|
|
600
|
+
if (name === 'list_lanes') {
|
|
601
|
+
const error = validationError(args, []);
|
|
602
|
+
if (error) return toolError(error);
|
|
603
|
+
return runToolScript('list_lanes', DISPATCH_SCRIPT, ['--list']);
|
|
604
|
+
}
|
|
605
|
+
if (name === 'jobs_status' || name === 'jobs_result') {
|
|
606
|
+
const error = validateJobArguments(args);
|
|
607
|
+
if (error) return toolError(error);
|
|
608
|
+
const command = name === 'jobs_status' ? 'status' : 'result';
|
|
609
|
+
return runToolScript(name, JOBS_SCRIPT, [command, args.id]);
|
|
610
|
+
}
|
|
611
|
+
if (name === 'jobs_list') {
|
|
612
|
+
const error = validateJsonOnlyArguments(args);
|
|
613
|
+
if (error) return toolError(error);
|
|
614
|
+
const argv = args.json === true ? ['--json', 'list'] : ['list'];
|
|
615
|
+
return runToolScript('jobs_list', JOBS_SCRIPT, argv);
|
|
616
|
+
}
|
|
617
|
+
if (name === 'explain') {
|
|
618
|
+
const error = validateExplainArguments(args);
|
|
619
|
+
if (error) return toolError(error);
|
|
620
|
+
const argv = ['--explain', args.lane];
|
|
621
|
+
if (args.json === true) argv.push('--json');
|
|
622
|
+
return runToolScript('explain', DISPATCH_SCRIPT, argv);
|
|
623
|
+
}
|
|
624
|
+
if (name === 'validate') {
|
|
625
|
+
const error = validateJsonOnlyArguments(args);
|
|
626
|
+
if (error) return toolError(error);
|
|
627
|
+
const argv = ['--validate'];
|
|
628
|
+
if (args.json === true) argv.push('--json');
|
|
629
|
+
return runToolScript('validate', DISPATCH_SCRIPT, argv);
|
|
630
|
+
}
|
|
631
|
+
if (name === 'dry_run') {
|
|
632
|
+
const error = validateDryRunArguments(args);
|
|
633
|
+
if (error) return toolError(error);
|
|
634
|
+
const mode = OWN(args, 'mode') ? args.mode : 'advise';
|
|
635
|
+
const argv = ['--dry-run', '--mode', mode];
|
|
636
|
+
for (const key of ['workdir', 'vendor', 'model', 'effort', 'timeout']) {
|
|
637
|
+
if (OWN(args, key)) argv.push(`--${key}`, String(args[key]));
|
|
638
|
+
}
|
|
639
|
+
argv.push(args.lane, args.task);
|
|
640
|
+
return runToolScript('dry_run', DISPATCH_SCRIPT, argv);
|
|
641
|
+
}
|
|
642
|
+
if (name === 'doctor') {
|
|
643
|
+
const error = validateJsonOnlyArguments(args);
|
|
644
|
+
if (error) return toolError(error);
|
|
645
|
+
const argv = args.json === true ? ['--json'] : [];
|
|
646
|
+
return runToolScript('doctor', DOCTOR_SCRIPT, argv);
|
|
647
|
+
}
|
|
648
|
+
if (name === 'jobs_stats' || name === 'jobs_audit') {
|
|
649
|
+
const error = validateJobsQueryArguments(args);
|
|
650
|
+
if (error) return toolError(error);
|
|
651
|
+
const argv = [];
|
|
652
|
+
if (args.json === true) argv.push('--json');
|
|
653
|
+
argv.push(name === 'jobs_stats' ? 'stats' : 'audit');
|
|
654
|
+
if (OWN(args, 'last')) argv.push('--last', String(args.last));
|
|
655
|
+
return runToolScript(name, JOBS_SCRIPT, argv);
|
|
656
|
+
}
|
|
657
|
+
throw new RpcError(-32602, `Unknown tool: ${name}`);
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
function initialize(params) {
|
|
661
|
+
if (!isObject(params) || typeof params.protocolVersion !== 'string') {
|
|
662
|
+
throw new RpcError(-32602, 'initialize requires a protocolVersion string');
|
|
663
|
+
}
|
|
664
|
+
const protocolVersion = SUPPORTED_PROTOCOL_VERSIONS.has(params.protocolVersion)
|
|
665
|
+
? params.protocolVersion
|
|
666
|
+
: LATEST_PROTOCOL_VERSION;
|
|
667
|
+
return {
|
|
668
|
+
protocolVersion,
|
|
669
|
+
capabilities: { tools: {} },
|
|
670
|
+
serverInfo: { name: 'omnilane', version: serverVersion },
|
|
671
|
+
};
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
async function handleRequest(message) {
|
|
675
|
+
switch (message.method) {
|
|
676
|
+
case 'initialize':
|
|
677
|
+
return initialize(message.params);
|
|
678
|
+
case 'tools/list':
|
|
679
|
+
if (message.params !== undefined && !isObject(message.params)) {
|
|
680
|
+
throw new RpcError(-32602, 'tools/list params must be an object');
|
|
681
|
+
}
|
|
682
|
+
return { tools };
|
|
683
|
+
case 'tools/call': {
|
|
684
|
+
if (!isObject(message.params) || typeof message.params.name !== 'string') {
|
|
685
|
+
throw new RpcError(-32602, 'tools/call requires a tool name');
|
|
686
|
+
}
|
|
687
|
+
const args = message.params.arguments === undefined ? {} : message.params.arguments;
|
|
688
|
+
return callTool(message.params.name, args);
|
|
689
|
+
}
|
|
690
|
+
default:
|
|
691
|
+
throw new RpcError(-32601, `Method not found: ${message.method}`);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
async function processLine(line) {
|
|
696
|
+
if (line.length === 0) return;
|
|
697
|
+
|
|
698
|
+
let message;
|
|
699
|
+
try {
|
|
700
|
+
message = JSON.parse(line);
|
|
701
|
+
} catch (error) {
|
|
702
|
+
writeError(null, -32700, 'Parse error');
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
const hasId = isObject(message) && OWN(message, 'id');
|
|
707
|
+
if (!isObject(message) || message.jsonrpc !== '2.0' || typeof message.method !== 'string') {
|
|
708
|
+
if (hasId) writeError(message.id, -32600, 'Invalid Request');
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
if (!hasId) return;
|
|
712
|
+
|
|
713
|
+
try {
|
|
714
|
+
writeResult(message.id, await handleRequest(message));
|
|
715
|
+
} catch (error) {
|
|
716
|
+
if (error instanceof RpcError) {
|
|
717
|
+
writeError(message.id, error.code, error.message, error.data);
|
|
718
|
+
} else {
|
|
719
|
+
process.stderr.write(`omnilane-mcp: internal error: ${error.message}\n`);
|
|
720
|
+
writeError(message.id, -32603, 'Internal error');
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
const input = readline.createInterface({
|
|
726
|
+
input: process.stdin,
|
|
727
|
+
crlfDelay: Infinity,
|
|
728
|
+
terminal: false,
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
input.on('line', (line) => {
|
|
732
|
+
processLine(line).catch((error) => {
|
|
733
|
+
process.stderr.write(`omnilane-mcp: internal error: ${error.message}\n`);
|
|
734
|
+
});
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
function shutdown() {
|
|
738
|
+
if (shuttingDown) return;
|
|
739
|
+
shuttingDown = true;
|
|
740
|
+
input.close();
|
|
741
|
+
for (const child of activeChildren) child.kill('SIGTERM');
|
|
742
|
+
if (activeChildren.size === 0) process.exit(0);
|
|
743
|
+
setTimeout(() => process.exit(0), 1000);
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
process.on('SIGINT', shutdown);
|
|
747
|
+
process.on('SIGTERM', shutdown);
|
|
748
|
+
process.stdout.on('error', (error) => {
|
|
749
|
+
if (error.code === 'EPIPE') {
|
|
750
|
+
outputClosed = true;
|
|
751
|
+
shutdown();
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
process.stderr.write(`omnilane-mcp: stdout error: ${error.message}\n`);
|
|
755
|
+
process.exit(1);
|
|
756
|
+
});
|