@ifrankwang/openspec-agents 0.143.0 → 0.145.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.
@@ -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 && this._taskMessageQueue) {
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.143.0";
30525
+ var PKG_VERSION = "0.145.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 createServer2 } from "node:http";
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.writeHead(status, {
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.writeHead(status, {
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.writeHead(status, {
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
- for (const key in header)
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
- header["Content-Length"] = Buffer.byteLength(body);
31620
+ length = Buffer.byteLength(body);
31538
31621
  else if (body instanceof Uint8Array)
31539
- header["Content-Length"] = body.byteLength;
31622
+ length = body.byteLength;
31540
31623
  else if (body instanceof Blob)
31541
- header["Content-Length"] = body.size;
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 = createServer(async (req, res) => {
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 = createServer2(async (req, res) => {
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,28 @@ 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 配置中加载 `@ifrankwang/openspec-agents` 插件,重启 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` 标记:插件升级后随版本刷新,你手工改过的同名文件不会被覆盖。
77
+
78
+ 配置项须写作 `plugins`(复数)。旧版的 `plugin`(单数)在 OpenCode 2 中不被识别,
79
+ 写了等于没写。
63
80
 
64
81
  ## 让 AI 帮你接入(一句话)
65
82
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ifrankwang/openspec-agents",
3
- "version": "0.143.0",
3
+ "version": "0.145.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
- import { readFileSync, existsSync, readdirSync } from "node:fs"
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 type { AgentConfig } from "@opencode-ai/sdk"
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
- export function injectAgents(config: Record<string, unknown>): void {
25
- if (existsSync(AGENTS_ROOT)) {
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
- const agentConfig: Record<string, unknown> = {
34
- description: frontmatter.description ?? "",
35
- mode: frontmatter.mode ?? "subagent",
36
- prompt: body,
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
- const existingAgentsMap = (config.agent as Record<string, unknown>) ?? {}
49
- const existingAgent = existingAgentsMap[name] as Record<string, unknown> | undefined
50
- config.agent = {
51
- ...existingAgentsMap,
52
- [name]: { ...agentConfig, ...existingAgent },
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
- function pathToName(filename: string): string | null {
59
- const base = filename.replace(/\.md$/i, "")
60
- return base || null
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 { type Plugin } from "@opencode-ai/plugin"
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 插件壳(MCP 形态):配置注入(agent/skill)+ MCP server 配置注入(config.mcp)。
8
- * 直载工具注册已移除(BREAKING,change agent-merge-and-mode-config 组 5.1):
9
- * - opx_* 工具统一经 MCP server 暴露(stdio bundle,--worktree 指向当前项目根),
10
- * 与 claude-code / zcode / codex 插件的 .mcp.json 形态一致;
11
- * - 身份不再由会话运行时推导,统一走 mcp-common 的 `_agent` 参数解析(缺省视为编排视角)。
12
- * dashboard/collector/poller 副作用已迁移至 MCP server 进程(src/adapters/mcp-common/)。
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 OpenspecOrchestratePlugin: Plugin = async (input) => {
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
- config: async (config) => {
17
- injectAgents(config)
18
- injectSkills(config)
19
- // MCP server 声明:opencode config 的 mcp 段(官方形态
20
- // { mcp: { <server>: { type: "local", command: [...] } } },见 opencode docs/mcp-servers)。
21
- // 入口为包内 .mcp-server/cli.mjs(prepack 构建的 MCP server 自包含 bundle),
22
- // --worktree 指向当前项目根(input.worktree 即 opencode 打开的 git worktree 路径)。
23
- config.mcp = {
24
- ...(config.mcp ?? {}),
25
- opx: {
26
- type: "local",
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
@@ -1,19 +1,57 @@
1
- import { existsSync } from "node:fs"
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
- export function injectSkills(config: Record<string, unknown>): void {
5
- const skillsCfg = (config.skills ?? {}) as Record<string, unknown>
6
- const paths = (skillsCfg.paths ?? []) as string[]
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
- if (!paths.includes(root)) {
11
- paths.push(root)
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
- if (paths.length > 0) {
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"