@ifrankwang/openspec-agents 0.143.0 → 0.144.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/.mcp-server/cli.mjs +122 -34
- package/README.md +15 -1
- package/package.json +3 -3
- package/src/adapters/opencode/agents.ts +60 -50
- package/src/adapters/opencode/index.ts +56 -34
- package/src/adapters/opencode/permissions.ts +0 -0
- package/src/adapters/opencode/skills.ts +50 -12
- package/src/index.ts +1 -1
package/.mcp-server/cli.mjs
CHANGED
|
@@ -19408,6 +19408,9 @@ class Protocol {
|
|
|
19408
19408
|
this.setRequestHandler(GetTaskPayloadRequestSchema, async (request, extra) => {
|
|
19409
19409
|
const handleTaskResult = async () => {
|
|
19410
19410
|
const taskId = request.params.taskId;
|
|
19411
|
+
if (!await this._taskStore.getTask(taskId, extra.sessionId)) {
|
|
19412
|
+
throw new McpError(ErrorCode.InvalidParams, `Task not found: ${taskId}`);
|
|
19413
|
+
}
|
|
19411
19414
|
if (this._taskMessageQueue) {
|
|
19412
19415
|
let queuedMessage;
|
|
19413
19416
|
while (queuedMessage = await this._taskMessageQueue.dequeue(taskId, extra.sessionId)) {
|
|
@@ -19438,12 +19441,12 @@ class Protocol {
|
|
|
19438
19441
|
throw new McpError(ErrorCode.InvalidParams, `Task not found: ${taskId}`);
|
|
19439
19442
|
}
|
|
19440
19443
|
if (!isTerminal(task.status)) {
|
|
19441
|
-
await this._waitForTaskUpdate(taskId, extra.signal);
|
|
19444
|
+
await this._waitForTaskUpdate(taskId, extra.signal, extra.sessionId);
|
|
19442
19445
|
return await handleTaskResult();
|
|
19443
19446
|
}
|
|
19444
19447
|
if (isTerminal(task.status)) {
|
|
19445
19448
|
const result = await this._taskStore.getTaskResult(taskId, extra.sessionId);
|
|
19446
|
-
this._clearTaskQueue(taskId);
|
|
19449
|
+
this._clearTaskQueue(taskId, extra.sessionId);
|
|
19447
19450
|
return {
|
|
19448
19451
|
...result,
|
|
19449
19452
|
_meta: {
|
|
@@ -19480,7 +19483,7 @@ class Protocol {
|
|
|
19480
19483
|
throw new McpError(ErrorCode.InvalidParams, `Cannot cancel task in terminal status: ${task.status}`);
|
|
19481
19484
|
}
|
|
19482
19485
|
await this._taskStore.updateTaskStatus(request.params.taskId, "cancelled", "Client cancelled task execution.", extra.sessionId);
|
|
19483
|
-
this._clearTaskQueue(request.params.taskId);
|
|
19486
|
+
this._clearTaskQueue(request.params.taskId, extra.sessionId);
|
|
19484
19487
|
const cancelledTask = await this._taskStore.getTask(request.params.taskId, extra.sessionId);
|
|
19485
19488
|
if (!cancelledTask) {
|
|
19486
19489
|
throw new McpError(ErrorCode.InvalidParams, `Task not found after cancellation: ${request.params.taskId}`);
|
|
@@ -19603,6 +19606,19 @@ class Protocol {
|
|
|
19603
19606
|
const handler = this._requestHandlers.get(request.method) ?? this.fallbackRequestHandler;
|
|
19604
19607
|
const capturedTransport = this._transport;
|
|
19605
19608
|
const relatedTaskId = request.params?._meta?.[RELATED_TASK_META_KEY]?.taskId;
|
|
19609
|
+
const sessionId = capturedTransport?.sessionId;
|
|
19610
|
+
const store = this._taskStore;
|
|
19611
|
+
let relatedTaskFound = true;
|
|
19612
|
+
let relatedTaskLookup;
|
|
19613
|
+
if (relatedTaskId && store && this._taskMessageQueue && sessionId !== undefined) {
|
|
19614
|
+
relatedTaskFound = false;
|
|
19615
|
+
relatedTaskLookup = (async () => {
|
|
19616
|
+
if (!await store.getTask(relatedTaskId, sessionId)) {
|
|
19617
|
+
throw new McpError(ErrorCode.InvalidParams, `Task not found: ${relatedTaskId}`);
|
|
19618
|
+
}
|
|
19619
|
+
relatedTaskFound = true;
|
|
19620
|
+
})();
|
|
19621
|
+
}
|
|
19606
19622
|
if (handler === undefined) {
|
|
19607
19623
|
const errorResponse = {
|
|
19608
19624
|
jsonrpc: "2.0",
|
|
@@ -19612,7 +19628,10 @@ class Protocol {
|
|
|
19612
19628
|
message: "Method not found"
|
|
19613
19629
|
}
|
|
19614
19630
|
};
|
|
19615
|
-
if (relatedTaskId &&
|
|
19631
|
+
if (relatedTaskId && relatedTaskLookup) {
|
|
19632
|
+
const queuedError = { type: "error", message: errorResponse, timestamp: Date.now() };
|
|
19633
|
+
relatedTaskLookup.then(() => this._enqueueTaskMessage(relatedTaskId, queuedError, sessionId), () => capturedTransport?.send(errorResponse)).catch((error) => this._onerror(new Error(`Failed to send an error response: ${error}`)));
|
|
19634
|
+
} else if (relatedTaskId && this._taskMessageQueue) {
|
|
19616
19635
|
this._enqueueTaskMessage(relatedTaskId, {
|
|
19617
19636
|
type: "error",
|
|
19618
19637
|
message: errorResponse,
|
|
@@ -19663,7 +19682,10 @@ class Protocol {
|
|
|
19663
19682
|
closeSSEStream: extra?.closeSSEStream,
|
|
19664
19683
|
closeStandaloneSSEStream: extra?.closeStandaloneSSEStream
|
|
19665
19684
|
};
|
|
19666
|
-
Promise.resolve().then(() => {
|
|
19685
|
+
(relatedTaskLookup ?? Promise.resolve()).then(() => {
|
|
19686
|
+
if (relatedTaskLookup && abortController.signal.aborted) {
|
|
19687
|
+
throw new McpError(ErrorCode.ConnectionClosed, "Request was cancelled");
|
|
19688
|
+
}
|
|
19667
19689
|
if (taskCreationParams) {
|
|
19668
19690
|
this.assertTaskHandlerCapability(request.method);
|
|
19669
19691
|
}
|
|
@@ -19698,7 +19720,7 @@ class Protocol {
|
|
|
19698
19720
|
...error["data"] !== undefined && { data: error["data"] }
|
|
19699
19721
|
}
|
|
19700
19722
|
};
|
|
19701
|
-
if (relatedTaskId && this._taskMessageQueue) {
|
|
19723
|
+
if (relatedTaskId && this._taskMessageQueue && relatedTaskFound) {
|
|
19702
19724
|
await this._enqueueTaskMessage(relatedTaskId, {
|
|
19703
19725
|
type: "error",
|
|
19704
19726
|
message: errorResponse,
|
|
@@ -20086,7 +20108,7 @@ class Protocol {
|
|
|
20086
20108
|
throw new Error("Cannot enqueue task message: taskStore and taskMessageQueue are not configured");
|
|
20087
20109
|
}
|
|
20088
20110
|
const maxQueueSize = this._options?.maxTaskQueueSize;
|
|
20089
|
-
await this._taskMessageQueue.enqueue(taskId, message, sessionId, maxQueueSize);
|
|
20111
|
+
await this._taskMessageQueue.enqueue(taskId, message, sessionId ?? this._transport?.sessionId, maxQueueSize);
|
|
20090
20112
|
}
|
|
20091
20113
|
async _clearTaskQueue(taskId, sessionId) {
|
|
20092
20114
|
if (this._taskMessageQueue) {
|
|
@@ -20105,10 +20127,10 @@ class Protocol {
|
|
|
20105
20127
|
}
|
|
20106
20128
|
}
|
|
20107
20129
|
}
|
|
20108
|
-
async _waitForTaskUpdate(taskId, signal) {
|
|
20130
|
+
async _waitForTaskUpdate(taskId, signal, sessionId) {
|
|
20109
20131
|
let interval = this._options?.defaultTaskPollInterval ?? 1000;
|
|
20110
20132
|
try {
|
|
20111
|
-
const task = await this._taskStore?.getTask(taskId);
|
|
20133
|
+
const task = await this._taskStore?.getTask(taskId, sessionId);
|
|
20112
20134
|
if (task?.pollInterval) {
|
|
20113
20135
|
interval = task.pollInterval;
|
|
20114
20136
|
}
|
|
@@ -20775,6 +20797,43 @@ class ExperimentalMcpServerTasks {
|
|
|
20775
20797
|
}
|
|
20776
20798
|
}
|
|
20777
20799
|
// node_modules/@modelcontextprotocol/sdk/dist/esm/server/mcp.js
|
|
20800
|
+
function toolInputElementCount(value, max) {
|
|
20801
|
+
let count = 0;
|
|
20802
|
+
const stack = [value];
|
|
20803
|
+
while (stack.length > 0) {
|
|
20804
|
+
const node = stack.pop();
|
|
20805
|
+
if (node === null || typeof node !== "object")
|
|
20806
|
+
continue;
|
|
20807
|
+
if (Array.isArray(node)) {
|
|
20808
|
+
for (const child of node) {
|
|
20809
|
+
if (++count > max)
|
|
20810
|
+
return count;
|
|
20811
|
+
if (child !== null && typeof child === "object")
|
|
20812
|
+
stack.push(child);
|
|
20813
|
+
}
|
|
20814
|
+
} else {
|
|
20815
|
+
for (const key in node) {
|
|
20816
|
+
if (!Object.prototype.hasOwnProperty.call(node, key))
|
|
20817
|
+
continue;
|
|
20818
|
+
if (++count > max)
|
|
20819
|
+
return count;
|
|
20820
|
+
const child = node[key];
|
|
20821
|
+
if (child !== null && typeof child === "object")
|
|
20822
|
+
stack.push(child);
|
|
20823
|
+
}
|
|
20824
|
+
}
|
|
20825
|
+
}
|
|
20826
|
+
return count;
|
|
20827
|
+
}
|
|
20828
|
+
function resolveMaxToolInputElements(value) {
|
|
20829
|
+
if (value === undefined || value === Infinity)
|
|
20830
|
+
return;
|
|
20831
|
+
if (typeof value !== "number" || Number.isNaN(value) || value < 1) {
|
|
20832
|
+
throw new RangeError(`maxToolInputElements must be a number of at least 1, or Infinity, got ${String(value)}`);
|
|
20833
|
+
}
|
|
20834
|
+
return value;
|
|
20835
|
+
}
|
|
20836
|
+
|
|
20778
20837
|
class McpServer {
|
|
20779
20838
|
constructor(serverInfo, options) {
|
|
20780
20839
|
this._registeredResources = {};
|
|
@@ -20786,6 +20845,7 @@ class McpServer {
|
|
|
20786
20845
|
this._resourceHandlersInitialized = false;
|
|
20787
20846
|
this._promptHandlersInitialized = false;
|
|
20788
20847
|
this.server = new Server(serverInfo, options);
|
|
20848
|
+
this._maxToolInputElements = resolveMaxToolInputElements(options?.maxToolInputElements);
|
|
20789
20849
|
}
|
|
20790
20850
|
get experimental() {
|
|
20791
20851
|
if (!this._experimental) {
|
|
@@ -20892,12 +20952,15 @@ class McpServer {
|
|
|
20892
20952
|
};
|
|
20893
20953
|
}
|
|
20894
20954
|
async validateToolInput(tool, args, toolName) {
|
|
20955
|
+
if (this._maxToolInputElements !== undefined && toolInputElementCount(args, this._maxToolInputElements) > this._maxToolInputElements) {
|
|
20956
|
+
throw new McpError(ErrorCode.InvalidParams, `Invalid arguments for tool ${toolName}: arguments contain more than the maximum of ${this._maxToolInputElements} elements`);
|
|
20957
|
+
}
|
|
20895
20958
|
if (!tool.inputSchema) {
|
|
20896
20959
|
return;
|
|
20897
20960
|
}
|
|
20898
20961
|
const inputObj = normalizeObjectSchema(tool.inputSchema);
|
|
20899
20962
|
const schemaToParse = inputObj ?? tool.inputSchema;
|
|
20900
|
-
const parseResult = await safeParseAsync3(schemaToParse, args);
|
|
20963
|
+
const parseResult = await safeParseAsync3(schemaToParse, args ?? {});
|
|
20901
20964
|
if (!parseResult.success) {
|
|
20902
20965
|
const error = "error" in parseResult ? parseResult.error : "Unknown error";
|
|
20903
20966
|
const errorMessage = getParseErrorMessage(error);
|
|
@@ -21122,7 +21185,7 @@ class McpServer {
|
|
|
21122
21185
|
}
|
|
21123
21186
|
if (prompt.argsSchema) {
|
|
21124
21187
|
const argsObj = normalizeObjectSchema(prompt.argsSchema);
|
|
21125
|
-
const parseResult = await safeParseAsync3(argsObj, request.params.arguments);
|
|
21188
|
+
const parseResult = await safeParseAsync3(argsObj, request.params.arguments ?? {});
|
|
21126
21189
|
if (!parseResult.success) {
|
|
21127
21190
|
const error = "error" in parseResult ? parseResult.error : "Unknown error";
|
|
21128
21191
|
const errorMessage = getParseErrorMessage(error);
|
|
@@ -30459,7 +30522,7 @@ async function ensureDefaultUnattended(args, ctx) {
|
|
|
30459
30522
|
}
|
|
30460
30523
|
} catch {}
|
|
30461
30524
|
}
|
|
30462
|
-
var PKG_VERSION = "0.
|
|
30525
|
+
var PKG_VERSION = "0.144.0";
|
|
30463
30526
|
function buildMcpServer(worktree, opts = {}) {
|
|
30464
30527
|
const mcp = new McpServer({ name: "openspec-agents", version: PKG_VERSION });
|
|
30465
30528
|
for (const [name, spec] of Object.entries(TOOL_SPECS)) {
|
|
@@ -30486,7 +30549,7 @@ function buildMcpServer(worktree, opts = {}) {
|
|
|
30486
30549
|
}
|
|
30487
30550
|
|
|
30488
30551
|
// src/adapters/mcp-common/index.ts
|
|
30489
|
-
import { createServer as
|
|
30552
|
+
import { createServer as createServer3 } from "node:http";
|
|
30490
30553
|
import { randomUUID } from "node:crypto";
|
|
30491
30554
|
import { join as join5 } from "node:path";
|
|
30492
30555
|
|
|
@@ -30494,6 +30557,7 @@ import { join as join5 } from "node:path";
|
|
|
30494
30557
|
var X_ALREADY_SENT = "x-hono-already-sent";
|
|
30495
30558
|
|
|
30496
30559
|
// node_modules/@hono/node-server/dist/index.mjs
|
|
30560
|
+
import { STATUS_CODES, ServerResponse, createServer } from "node:http";
|
|
30497
30561
|
import { Http2ServerRequest, constants } from "node:http2";
|
|
30498
30562
|
import { Readable } from "node:stream";
|
|
30499
30563
|
|
|
@@ -31476,8 +31540,11 @@ var handleResponseError = (e, outgoing) => {
|
|
|
31476
31540
|
console.info("The user aborted a request.");
|
|
31477
31541
|
else {
|
|
31478
31542
|
console.error(e);
|
|
31479
|
-
if (!outgoing.headersSent)
|
|
31543
|
+
if (!outgoing.headersSent) {
|
|
31544
|
+
if (outgoing instanceof ServerResponse)
|
|
31545
|
+
outgoing._contentLength = null;
|
|
31480
31546
|
outgoing.writeHead(500, { "Content-Type": "text/plain" });
|
|
31547
|
+
}
|
|
31481
31548
|
outgoing.end(`Error: ${err.message}`);
|
|
31482
31549
|
outgoing.destroy(err);
|
|
31483
31550
|
}
|
|
@@ -31486,6 +31553,23 @@ var flushHeaders = (outgoing) => {
|
|
|
31486
31553
|
if ("flushHeaders" in outgoing && outgoing.writable)
|
|
31487
31554
|
outgoing.flushHeaders();
|
|
31488
31555
|
};
|
|
31556
|
+
var trySetContentLength = (outgoing, status, length) => {
|
|
31557
|
+
const http1 = outgoing;
|
|
31558
|
+
if (http1._contentLength === null && http1._hasBody && http1.useChunkedEncodingByDefault && !http1._removedContLen && status >= 200 && status !== 204 && status !== 304 && !outgoing.hasHeader("content-length") && !outgoing.hasHeader("transfer-encoding") && !outgoing.hasHeader("trailer")) {
|
|
31559
|
+
http1._contentLength = length;
|
|
31560
|
+
return true;
|
|
31561
|
+
}
|
|
31562
|
+
return false;
|
|
31563
|
+
};
|
|
31564
|
+
var writeDefaultHeaders = (outgoing, status, length) => {
|
|
31565
|
+
if (trySetContentLength(outgoing, status, length))
|
|
31566
|
+
outgoing.writeHead(status, { "Content-Type": defaultContentType });
|
|
31567
|
+
else
|
|
31568
|
+
outgoing.writeHead(status, {
|
|
31569
|
+
"Content-Type": defaultContentType,
|
|
31570
|
+
"Content-Length": length
|
|
31571
|
+
});
|
|
31572
|
+
};
|
|
31489
31573
|
var responseViaCache = async (res, outgoing) => {
|
|
31490
31574
|
let [status, body, header] = res[cacheKey2];
|
|
31491
31575
|
if (!header) {
|
|
@@ -31493,22 +31577,13 @@ var responseViaCache = async (res, outgoing) => {
|
|
|
31493
31577
|
outgoing.writeHead(status);
|
|
31494
31578
|
outgoing.end();
|
|
31495
31579
|
} else if (typeof body === "string") {
|
|
31496
|
-
outgoing
|
|
31497
|
-
"Content-Type": defaultContentType,
|
|
31498
|
-
"Content-Length": Buffer.byteLength(body)
|
|
31499
|
-
});
|
|
31580
|
+
writeDefaultHeaders(outgoing, status, Buffer.byteLength(body));
|
|
31500
31581
|
outgoing.end(body);
|
|
31501
31582
|
} else if (body instanceof Uint8Array) {
|
|
31502
|
-
outgoing
|
|
31503
|
-
"Content-Type": defaultContentType,
|
|
31504
|
-
"Content-Length": body.byteLength
|
|
31505
|
-
});
|
|
31583
|
+
writeDefaultHeaders(outgoing, status, body.byteLength);
|
|
31506
31584
|
outgoing.end(body);
|
|
31507
31585
|
} else if (body instanceof Blob) {
|
|
31508
|
-
outgoing
|
|
31509
|
-
"Content-Type": defaultContentType,
|
|
31510
|
-
"Content-Length": body.size
|
|
31511
|
-
});
|
|
31586
|
+
writeDefaultHeaders(outgoing, status, body.size);
|
|
31512
31587
|
outgoing.end(new Uint8Array(await body.arrayBuffer()));
|
|
31513
31588
|
} else {
|
|
31514
31589
|
outgoing.writeHead(status, { "Content-Type": defaultContentType });
|
|
@@ -31519,6 +31594,8 @@ var responseViaCache = async (res, outgoing) => {
|
|
|
31519
31594
|
return;
|
|
31520
31595
|
}
|
|
31521
31596
|
let hasContentLength = false;
|
|
31597
|
+
let plainHeaders = false;
|
|
31598
|
+
let canAutoLength = true;
|
|
31522
31599
|
if (header instanceof Headers) {
|
|
31523
31600
|
hasContentLength = header.has("content-length");
|
|
31524
31601
|
header = buildOutgoingHttpHeaders(header, body === null ? undefined : defaultContentType);
|
|
@@ -31526,19 +31603,30 @@ var responseViaCache = async (res, outgoing) => {
|
|
|
31526
31603
|
const headerObj = new Headers(header);
|
|
31527
31604
|
hasContentLength = headerObj.has("content-length");
|
|
31528
31605
|
header = buildOutgoingHttpHeaders(headerObj, body === null ? undefined : defaultContentType);
|
|
31529
|
-
} else
|
|
31530
|
-
|
|
31606
|
+
} else {
|
|
31607
|
+
plainHeaders = true;
|
|
31608
|
+
for (const key in header) {
|
|
31531
31609
|
if (key.length === 14 && key.toLowerCase() === "content-length") {
|
|
31532
31610
|
hasContentLength = true;
|
|
31533
31611
|
break;
|
|
31534
31612
|
}
|
|
31613
|
+
if (key.length === 17 && key.toLowerCase() === "transfer-encoding" || key.length === 7 && key.toLowerCase() === "trailer")
|
|
31614
|
+
canAutoLength = false;
|
|
31615
|
+
}
|
|
31616
|
+
}
|
|
31535
31617
|
if (!hasContentLength) {
|
|
31618
|
+
let length;
|
|
31536
31619
|
if (typeof body === "string")
|
|
31537
|
-
|
|
31620
|
+
length = Buffer.byteLength(body);
|
|
31538
31621
|
else if (body instanceof Uint8Array)
|
|
31539
|
-
|
|
31622
|
+
length = body.byteLength;
|
|
31540
31623
|
else if (body instanceof Blob)
|
|
31541
|
-
|
|
31624
|
+
length = body.size;
|
|
31625
|
+
if (length !== undefined && (!plainHeaders || !canAutoLength || !trySetContentLength(outgoing, status, length))) {
|
|
31626
|
+
if (plainHeaders)
|
|
31627
|
+
header = { ...header };
|
|
31628
|
+
header["Content-Length"] = length;
|
|
31629
|
+
}
|
|
31542
31630
|
}
|
|
31543
31631
|
outgoing.writeHead(status, header);
|
|
31544
31632
|
if (body == null)
|
|
@@ -32693,7 +32781,7 @@ class StreamableHTTPServerTransport {
|
|
|
32693
32781
|
}
|
|
32694
32782
|
|
|
32695
32783
|
// src/adapters/opencode/dashboard.ts
|
|
32696
|
-
import { createServer } from "node:http";
|
|
32784
|
+
import { createServer as createServer2 } from "node:http";
|
|
32697
32785
|
|
|
32698
32786
|
// src/core/dashboard.ts
|
|
32699
32787
|
import { readdirSync as readdirSync4, existsSync as existsSync5 } from "node:fs";
|
|
@@ -32798,7 +32886,7 @@ async function startDashboard(worktree) {
|
|
|
32798
32886
|
const pageHtml = getDashboardPage();
|
|
32799
32887
|
for (let port = BASE_PORT;port < BASE_PORT + MAX_ATTEMPTS; port++) {
|
|
32800
32888
|
try {
|
|
32801
|
-
const server =
|
|
32889
|
+
const server = createServer2(async (req, res) => {
|
|
32802
32890
|
try {
|
|
32803
32891
|
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "127.0.0.1"}`);
|
|
32804
32892
|
if (url.pathname === "/api/state") {
|
|
@@ -33046,7 +33134,7 @@ async function startMcpServer(opts) {
|
|
|
33046
33134
|
const port = opts.port ?? DEFAULT_PORT;
|
|
33047
33135
|
const hostname = opts.hostname ?? "127.0.0.1";
|
|
33048
33136
|
const transports = new Map;
|
|
33049
|
-
const server =
|
|
33137
|
+
const server = createServer3(async (req, res) => {
|
|
33050
33138
|
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? hostname}`);
|
|
33051
33139
|
if (url.pathname === "/mcp") {
|
|
33052
33140
|
const sessionId = Array.isArray(req.headers["mcp-session-id"]) ? req.headers["mcp-session-id"][0] : req.headers["mcp-session-id"];
|
package/README.md
CHANGED
|
@@ -55,11 +55,25 @@ dsh plugin --profile web add @ifrankwang/openspec-agents
|
|
|
55
55
|
|
|
56
56
|
### OpenCode
|
|
57
57
|
|
|
58
|
+
要求 OpenCode 2.x(1.x 的插件接口不兼容,本插件只支持 2.x)。
|
|
59
|
+
|
|
58
60
|
```bash
|
|
59
61
|
npm install -D @ifrankwang/openspec-agents
|
|
60
62
|
```
|
|
61
63
|
|
|
62
|
-
然后在 OpenCode
|
|
64
|
+
然后在 OpenCode 配置的 `plugins` 中登记 `@ifrankwang/openspec-agents`,重启 OpenCode:
|
|
65
|
+
|
|
66
|
+
```jsonc
|
|
67
|
+
// opencode.json
|
|
68
|
+
{
|
|
69
|
+
"$schema": "https://opencode.ai/config.json",
|
|
70
|
+
"plugins": ["@ifrankwang/openspec-agents"]
|
|
71
|
+
}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
首次加载时,插件会在项目 `.opencode/agents/` 下生成三个子代理定义文件
|
|
75
|
+
(`openspec-main` / `openspec-developer` / `openspec-reviewer`)。这些文件带
|
|
76
|
+
`generated-by` 标记:插件升级后随版本刷新,你手工改过的同名文件不会被覆盖。
|
|
63
77
|
|
|
64
78
|
## 让 AI 帮你接入(一句话)
|
|
65
79
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ifrankwang/openspec-agents",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.144.0",
|
|
4
4
|
"description": "OpenSpec change orchestration plugin for AI coding agents (Claude Code, Codex, ZCode, DeepSeek Harness, OpenCode)",
|
|
5
5
|
"author": "ifrankwang",
|
|
6
6
|
"license": "MIT",
|
|
@@ -75,14 +75,14 @@
|
|
|
75
75
|
"typecheck": "tsc --noEmit"
|
|
76
76
|
},
|
|
77
77
|
"devDependencies": {
|
|
78
|
+
"@opencode/plugin": "^2.0.26",
|
|
78
79
|
"@types/bun": "^1.3.0",
|
|
79
80
|
"@types/js-yaml": "^4.0.9",
|
|
80
81
|
"typescript": "^5.9.0"
|
|
81
82
|
},
|
|
82
83
|
"dependencies": {
|
|
83
84
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
84
|
-
"js-yaml": "^4.1.0"
|
|
85
|
-
"@opencode-ai/plugin": "^1.17.0"
|
|
85
|
+
"js-yaml": "^4.1.0"
|
|
86
86
|
},
|
|
87
87
|
"dsh": {
|
|
88
88
|
"bundle": {
|
|
@@ -1,61 +1,71 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* 子代理定义投放:assets/agents/*.md 转成 OpenCode V2 原生 agent 文件,写入目标项目
|
|
3
|
+
* `.opencode/agents/<名>.md`——V2 发现项目级 agent 的位置。
|
|
4
|
+
*
|
|
5
|
+
* V2 插件接口的 agent 编辑器只提供 update/remove,没有新增能力,而 V2 也不从插件包目录发现
|
|
6
|
+
* agent,因此新增子代理只能走文件投放,与 codex 适配器把 agent 写进目标仓库 `.codex/agents/`
|
|
7
|
+
* 的做法同构。
|
|
8
|
+
*
|
|
9
|
+
* frontmatter 按 V2 字段落地:mode/steps/hidden 沿用同名,permission 对象展开为 permissions
|
|
10
|
+
* 有序数组(见 permissions.ts)。文件正文首行带生成标记:已存在且带标记的文件随插件版本刷新,
|
|
11
|
+
* 用户手工改写过的同名文件保留不动。
|
|
12
|
+
*/
|
|
13
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync } from "node:fs"
|
|
2
14
|
import { join } from "node:path"
|
|
3
|
-
import
|
|
15
|
+
import * as yaml from "js-yaml"
|
|
4
16
|
import { parseAgentMd, resolve } from "../agent-md.ts"
|
|
5
|
-
|
|
6
|
-
function mapPermission(
|
|
7
|
-
fm: Record<string, unknown>
|
|
8
|
-
): AgentConfig["permission"] | undefined {
|
|
9
|
-
const raw = fm.permission as Record<string, unknown> | undefined
|
|
10
|
-
if (!raw) return undefined
|
|
11
|
-
const permission: AgentConfig["permission"] = {}
|
|
12
|
-
for (const [key, val] of Object.entries(raw)) {
|
|
13
|
-
if (typeof val === "string" && ["ask", "allow", "deny"].includes(val)) {
|
|
14
|
-
;(permission as any)[key] = val
|
|
15
|
-
} else if (typeof val === "object" && val !== null) {
|
|
16
|
-
;(permission as any)[key] = val
|
|
17
|
-
}
|
|
18
|
-
}
|
|
19
|
-
return permission
|
|
20
|
-
}
|
|
17
|
+
import { toV2Permissions } from "./permissions.ts"
|
|
21
18
|
|
|
22
19
|
const AGENTS_ROOT = resolve("assets", "agents")
|
|
23
20
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
const files = readdirSync(AGENTS_ROOT).filter((f) => f.endsWith(".md"))
|
|
27
|
-
for (const file of files) {
|
|
28
|
-
const md = readFileSync(join(AGENTS_ROOT, file), "utf-8")
|
|
29
|
-
const { frontmatter, body } = parseAgentMd(md)
|
|
30
|
-
const name = pathToName(file) ?? (frontmatter.name as string)
|
|
31
|
-
if (!name) continue
|
|
21
|
+
/** 生成标记:正文首行。用于识别「本文件由插件生成、允许随版本刷新」。 */
|
|
22
|
+
const GENERATED_MARKER = "<!-- generated-by: openspec-agents -->"
|
|
32
23
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
}
|
|
38
|
-
if (frontmatter.hidden !== undefined) {
|
|
39
|
-
agentConfig.hidden = frontmatter.hidden
|
|
40
|
-
}
|
|
41
|
-
const maxSteps = (frontmatter.maxSteps ?? frontmatter.steps) as number | undefined
|
|
42
|
-
if (maxSteps !== undefined) {
|
|
43
|
-
agentConfig.maxSteps = maxSteps
|
|
44
|
-
}
|
|
45
|
-
const perm = mapPermission(frontmatter)
|
|
46
|
-
if (perm) agentConfig.permission = perm
|
|
24
|
+
/** 项目内 agent 投放目录(V2 发现位置)。 */
|
|
25
|
+
export function agentsTargetDir(projectRoot: string): string {
|
|
26
|
+
return join(projectRoot, ".opencode", "agents")
|
|
27
|
+
}
|
|
47
28
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
29
|
+
/**
|
|
30
|
+
* 把随包分发的子代理写入目标项目,返回本次实际写入的 agent 名列表。
|
|
31
|
+
* assets/agents 缺失(分发形态裁剪)时返回空列表,不阻断插件启动。
|
|
32
|
+
*/
|
|
33
|
+
export function writeAgents(projectRoot: string): string[] {
|
|
34
|
+
const written: string[] = []
|
|
35
|
+
if (!existsSync(AGENTS_ROOT)) return written
|
|
36
|
+
const targetDir = agentsTargetDir(projectRoot)
|
|
37
|
+
for (const file of readdirSync(AGENTS_ROOT)) {
|
|
38
|
+
if (!file.endsWith(".md")) continue
|
|
39
|
+
const { frontmatter, body } = parseAgentMd(readFileSync(join(AGENTS_ROOT, file), "utf-8"))
|
|
40
|
+
const name = (frontmatter.name as string) ?? file.replace(/\.md$/, "")
|
|
41
|
+
if (!name) continue
|
|
42
|
+
const target = join(targetDir, `${name}.md`)
|
|
43
|
+
if (!isOverwritable(target)) continue
|
|
44
|
+
mkdirSync(targetDir, { recursive: true })
|
|
45
|
+
writeFileSync(target, renderAgentFile(frontmatter, body), "utf-8")
|
|
46
|
+
written.push(name)
|
|
55
47
|
}
|
|
48
|
+
return written
|
|
56
49
|
}
|
|
57
50
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
51
|
+
/** 未投放过、或上次由本插件生成的文件可覆盖;用户手工维护的文件一律保留。 */
|
|
52
|
+
function isOverwritable(target: string): boolean {
|
|
53
|
+
if (!existsSync(target)) return true
|
|
54
|
+
try {
|
|
55
|
+
return readFileSync(target, "utf-8").includes(GENERATED_MARKER)
|
|
56
|
+
} catch {
|
|
57
|
+
return false
|
|
58
|
+
}
|
|
61
59
|
}
|
|
60
|
+
|
|
61
|
+
function renderAgentFile(fm: Record<string, unknown>, body: string): string {
|
|
62
|
+
const frontmatter: Record<string, unknown> = {}
|
|
63
|
+
if (fm.description !== undefined) frontmatter.description = fm.description
|
|
64
|
+
if (fm.mode !== undefined) frontmatter.mode = fm.mode
|
|
65
|
+
if (fm.hidden !== undefined) frontmatter.hidden = fm.hidden
|
|
66
|
+
const steps = (fm.steps ?? fm.maxSteps) as number | undefined
|
|
67
|
+
if (steps !== undefined) frontmatter.steps = steps
|
|
68
|
+
const permissions = toV2Permissions(fm.permission)
|
|
69
|
+
if (permissions.length > 0) frontmatter.permissions = permissions
|
|
70
|
+
return `---\n${yaml.dump(frontmatter)}---\n\n${GENERATED_MARKER}\n\n${body.trim()}\n`
|
|
71
|
+
}
|
|
@@ -1,41 +1,63 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { injectSkills } from "./skills.ts"
|
|
3
|
-
import { injectAgents } from "./agents.ts"
|
|
1
|
+
import type { Plugin } from "@opencode/plugin"
|
|
4
2
|
import { resolve } from "../agent-md.ts"
|
|
3
|
+
import { collectSkills, type SkillRegistration } from "./skills.ts"
|
|
4
|
+
import { writeAgents } from "./agents.ts"
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
* OpenCode
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
7
|
+
* OpenCode V2 插件壳:MCP server 注册 + skill 注册 + 子代理文件投放。
|
|
8
|
+
*
|
|
9
|
+
* V2 插件以默认导出 { id, setup } 生效,setup 内按域注册,不再持有全局配置对象:
|
|
10
|
+
* - MCP server 走 mcp 域 transform 声明(local + stdio command 数组),opx_* 工具由此暴露,
|
|
11
|
+
* 身份统一由 mcp-common 的 `_agent` 参数解析(缺省视为编排视角);
|
|
12
|
+
* - 随包 skill 走 skill 域 transform 逐项注册,path 指向包内 SKILL.md 以保留附属文件基准;
|
|
13
|
+
* - 子代理以文件形式投放至项目 `.opencode/agents/`(V2 插件接口无新增 agent 的能力)。
|
|
14
|
+
*
|
|
15
|
+
* dashboard/collector/poller 副作用由 MCP server 进程承担(src/adapters/mcp-common/),
|
|
16
|
+
* 插件壳不启动任何后台任务。
|
|
17
|
+
*
|
|
18
|
+
* 插件 ID 稳定为 openspec-agents:V2 以该 ID 标识插件、隔离其持久化存储,并用于配置中的
|
|
19
|
+
* 启用/禁用前缀匹配。
|
|
13
20
|
*/
|
|
14
|
-
export const
|
|
21
|
+
export const OPENCODE_PLUGIN_ID = "openspec-agents"
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 构造 opx MCP server 配置。入口为包内 .mcp-server/cli.mjs(prepack 构建的自包含 bundle),
|
|
25
|
+
* --worktree 指向当前加载本插件的项目根(ctx.location.directory),dashboard/collector 副作用
|
|
26
|
+
* 与状态读写均以此为根。
|
|
27
|
+
*/
|
|
28
|
+
function mcpServerConfig(projectRoot: string) {
|
|
15
29
|
return {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
command: [
|
|
28
|
-
"node",
|
|
29
|
-
resolve(".mcp-server", "cli.mjs"),
|
|
30
|
-
"--transport",
|
|
31
|
-
"stdio",
|
|
32
|
-
"--worktree",
|
|
33
|
-
input.worktree,
|
|
34
|
-
"--unattended",
|
|
35
|
-
"--strip-opx-prefix",
|
|
36
|
-
],
|
|
37
|
-
},
|
|
38
|
-
}
|
|
39
|
-
},
|
|
30
|
+
type: "local" as const,
|
|
31
|
+
command: [
|
|
32
|
+
"node",
|
|
33
|
+
resolve(".mcp-server", "cli.mjs"),
|
|
34
|
+
"--transport",
|
|
35
|
+
"stdio",
|
|
36
|
+
"--worktree",
|
|
37
|
+
projectRoot,
|
|
38
|
+
"--unattended",
|
|
39
|
+
"--strip-opx-prefix",
|
|
40
|
+
],
|
|
40
41
|
}
|
|
41
42
|
}
|
|
43
|
+
|
|
44
|
+
const plugin: Plugin.Plugin = {
|
|
45
|
+
id: OPENCODE_PLUGIN_ID,
|
|
46
|
+
async setup(ctx) {
|
|
47
|
+
const projectRoot = ctx.location.directory
|
|
48
|
+
|
|
49
|
+
await ctx.mcp.transform((editor) => {
|
|
50
|
+
editor.set("opx", mcpServerConfig(projectRoot))
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
await ctx.skill.transform((editor) => {
|
|
54
|
+
for (const skill of collectSkills()) {
|
|
55
|
+
editor.add(skill)
|
|
56
|
+
}
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
writeAgents(projectRoot)
|
|
60
|
+
},
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export default plugin
|
|
Binary file
|
|
@@ -1,19 +1,57 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* skill 投放:收集随包分发的 skill,交由 V2 skill 域的 transform 逐个注册。
|
|
3
|
+
*
|
|
4
|
+
* V1 形态把 skill 根目录追加进 config.skills.paths,由 opencode 自行发现;V2 插件不持有全局
|
|
5
|
+
* 配置对象,skill 改由 skill 域显式注册。注册项的 path 指向包内 SKILL.md,使 skill 加载时
|
|
6
|
+
* 仍以该目录为附属文件(reference/、scripts/ 等)的相对引用基准。
|
|
7
|
+
*
|
|
8
|
+
* ID 取目录名(V2 以路径派生 ID 为准),name/description 取 SKILL.md frontmatter,
|
|
9
|
+
* content 取去 frontmatter 的正文——与 V2 加载时「加入去掉 frontmatter 的正文」一致。
|
|
10
|
+
*/
|
|
11
|
+
import { readFileSync, existsSync, readdirSync } from "node:fs"
|
|
12
|
+
import { join } from "node:path"
|
|
13
|
+
import type { Skill } from "@opencode/plugin"
|
|
14
|
+
import { parseAgentMd } from "../agent-md.ts"
|
|
2
15
|
import { DISTRIBUTED_SKILL_ROOTS } from "../../skills/scan.ts"
|
|
3
16
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
17
|
+
/**
|
|
18
|
+
* 注册一项 skill 所需的字段(对齐 V2 Skill.Info 中由插件负责提供的部分)。
|
|
19
|
+
* id/name/path 在 V2 schema 中为品牌化字符串(编译期约束,运行时即普通 string),
|
|
20
|
+
* 构造处统一以断言完成品牌化,避免引入 @opencode/plugin 的运行时依赖
|
|
21
|
+
* (Plugin.define 为恒等函数,插件壳不需要该包的运行时实例)。
|
|
22
|
+
*/
|
|
23
|
+
export interface SkillRegistration {
|
|
24
|
+
id: Skill.Info["id"]
|
|
25
|
+
name: Skill.Info["name"]
|
|
26
|
+
description?: string
|
|
27
|
+
path: Skill.Info["path"]
|
|
28
|
+
content: string
|
|
29
|
+
}
|
|
7
30
|
|
|
31
|
+
/** 收集随包分发的全部 skill;随包 skill 缺失(分发形态裁剪)时返回空数组。 */
|
|
32
|
+
export function collectSkills(): SkillRegistration[] {
|
|
33
|
+
const out: SkillRegistration[] = []
|
|
34
|
+
const seen = new Set<string>()
|
|
8
35
|
for (const root of DISTRIBUTED_SKILL_ROOTS) {
|
|
9
36
|
if (!existsSync(root)) continue
|
|
10
|
-
|
|
11
|
-
|
|
37
|
+
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
|
38
|
+
if (!entry.isDirectory()) continue
|
|
39
|
+
const skillFile = join(root, entry.name, "SKILL.md")
|
|
40
|
+
if (!existsSync(skillFile)) continue
|
|
41
|
+
// 多根场景下同名 skill 只注册一次(V2 按 ID 选择,后注册者覆盖前者)。
|
|
42
|
+
if (seen.has(entry.name)) continue
|
|
43
|
+
seen.add(entry.name)
|
|
44
|
+
const { frontmatter, body } = parseAgentMd(readFileSync(skillFile, "utf-8"))
|
|
45
|
+
// id 由目录名派生(V2 以路径派生 ID 为准),name 为展示名,缺失时回落 id。
|
|
46
|
+
const name = (frontmatter.name as string) ?? entry.name
|
|
47
|
+
out.push({
|
|
48
|
+
id: entry.name as Skill.Info["id"],
|
|
49
|
+
name: name as Skill.Info["name"],
|
|
50
|
+
description: frontmatter.description as string | undefined,
|
|
51
|
+
path: skillFile as Skill.Info["path"],
|
|
52
|
+
content: body,
|
|
53
|
+
})
|
|
12
54
|
}
|
|
13
55
|
}
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
skillsCfg.paths = paths
|
|
17
|
-
config.skills = skillsCfg
|
|
18
|
-
}
|
|
19
|
-
}
|
|
56
|
+
return out
|
|
57
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// 旧入口保留向后兼容
|
|
2
|
-
export { OpenspecOrchestratePlugin } from "./adapters/opencode/index.ts"
|
|
2
|
+
export { default as OpenspecOrchestratePlugin } from "./adapters/opencode/index.ts"
|