mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-08-06 01:14:36 +08:00
feat(agent): add local Codex Skill management and invocation
This commit is contained in:
Generated
+125
@@ -11,8 +11,10 @@
|
||||
"@modelcontextprotocol/sdk": "^1.12.1",
|
||||
"@openai/codex": "0.146.0",
|
||||
"express": "^5.1.0",
|
||||
"gray-matter": "^4.0.3",
|
||||
"strip-ansi": "7.2.0",
|
||||
"winston": "^3.19.0",
|
||||
"yaml": "^2.9.0",
|
||||
"zod": "^3.25.0"
|
||||
},
|
||||
"bin": {
|
||||
@@ -371,6 +373,15 @@
|
||||
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/argparse": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
|
||||
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"sprintf-js": "~1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/async": {
|
||||
"version": "3.2.6",
|
||||
"license": "MIT"
|
||||
@@ -643,6 +654,19 @@
|
||||
"version": "1.0.3",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/esprima": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
|
||||
"integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
|
||||
"license": "BSD-2-Clause",
|
||||
"bin": {
|
||||
"esparse": "bin/esparse.js",
|
||||
"esvalidate": "bin/esvalidate.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/etag": {
|
||||
"version": "1.8.1",
|
||||
"license": "MIT",
|
||||
@@ -724,6 +748,18 @@
|
||||
"express": ">= 4.11"
|
||||
}
|
||||
},
|
||||
"node_modules/extend-shallow": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
|
||||
"integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-extendable": "^0.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"license": "MIT"
|
||||
@@ -845,6 +881,21 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/gray-matter": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz",
|
||||
"integrity": "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"js-yaml": "^3.13.1",
|
||||
"kind-of": "^6.0.2",
|
||||
"section-matter": "^1.0.0",
|
||||
"strip-bom-string": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/has-symbols": {
|
||||
"version": "1.1.0",
|
||||
"license": "MIT",
|
||||
@@ -922,6 +973,15 @@
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/is-extendable": {
|
||||
"version": "0.1.1",
|
||||
"resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
|
||||
"integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-promise": {
|
||||
"version": "4.0.0",
|
||||
"license": "MIT"
|
||||
@@ -947,6 +1007,19 @@
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "3.15.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.1.tgz",
|
||||
"integrity": "sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^1.0.7",
|
||||
"esprima": "^4.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"js-yaml": "bin/js-yaml.js"
|
||||
}
|
||||
},
|
||||
"node_modules/json-schema-traverse": {
|
||||
"version": "1.0.0",
|
||||
"license": "MIT"
|
||||
@@ -955,6 +1028,15 @@
|
||||
"version": "8.0.2",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/kind-of": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
|
||||
"integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/kuler": {
|
||||
"version": "2.0.0",
|
||||
"license": "MIT"
|
||||
@@ -1206,6 +1288,19 @@
|
||||
"version": "2.1.2",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/section-matter": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz",
|
||||
"integrity": "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"extend-shallow": "^2.0.1",
|
||||
"kind-of": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/send": {
|
||||
"version": "1.2.1",
|
||||
"license": "MIT",
|
||||
@@ -1332,6 +1427,12 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/sprintf-js": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
|
||||
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/stack-trace": {
|
||||
"version": "0.0.10",
|
||||
"license": "MIT",
|
||||
@@ -1368,6 +1469,15 @@
|
||||
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-bom-string": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz",
|
||||
"integrity": "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/text-hex": {
|
||||
"version": "1.0.0",
|
||||
"license": "MIT"
|
||||
@@ -1514,6 +1624,21 @@
|
||||
"version": "1.0.2",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yaml": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
|
||||
"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"yaml": "bin.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/eemeli"
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "3.25.76",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"scripts": {
|
||||
"dev": "tsx src/index.ts",
|
||||
"debug": "tsx src/index.ts --debug",
|
||||
"test": "tsx --test src/canvas/session.test.ts src/agent/codex-client.test.ts src/agent/codex-history.test.ts",
|
||||
"test": "tsx --test src/canvas/session.test.ts src/agent/codex-client.test.ts src/agent/codex-history.test.ts src/skills/store.test.ts",
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"start": "node dist/index.js",
|
||||
"prepack": "npm run build"
|
||||
@@ -24,8 +24,10 @@
|
||||
"@modelcontextprotocol/sdk": "^1.12.1",
|
||||
"@openai/codex": "0.146.0",
|
||||
"express": "^5.1.0",
|
||||
"gray-matter": "^4.0.3",
|
||||
"strip-ansi": "7.2.0",
|
||||
"winston": "^3.19.0",
|
||||
"yaml": "^2.9.0",
|
||||
"zod": "^3.25.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -57,6 +57,89 @@ test("中断请求只作用于当前运行线程", async () => {
|
||||
assert.equal(await interrupt, true);
|
||||
});
|
||||
|
||||
test("Skill 列表与启用配置使用 app-server 原生协议", async () => {
|
||||
const writes: Array<Record<string, unknown>> = [];
|
||||
const child = { stdin: { write: (line: string) => (writes.push(JSON.parse(line)), true) } };
|
||||
const client = Reflect.construct(CodexAppClient, [child, () => undefined, emptyEventHistory]) as CodexAppClient;
|
||||
const testClient = client as unknown as TestClient;
|
||||
|
||||
const listing = client.listSkills("D:\\site", true);
|
||||
const listRequest = writes.at(-1);
|
||||
assert.deepEqual(listRequest, { id: 1, method: "skills/list", params: { cwds: ["D:\\site"], forceReload: true } });
|
||||
testClient.handle({ id: listRequest?.id, result: { data: [{ cwd: "D:\\site", skills: [], errors: [] }] } });
|
||||
assert.equal((await listing).data[0]?.cwd, "D:\\site");
|
||||
|
||||
const configuring = client.setSkillEnabled("D:\\site\\.agents\\skills\\demo\\SKILL.md", false);
|
||||
const configRequest = writes.at(-1);
|
||||
assert.deepEqual(configRequest, { id: 2, method: "skills/config/write", params: { path: "D:\\site\\.agents\\skills\\demo\\SKILL.md", enabled: false } });
|
||||
testClient.handle({ id: configRequest?.id, result: { effectiveEnabled: false } });
|
||||
assert.equal((await configuring).effectiveEnabled, false);
|
||||
});
|
||||
|
||||
test("并发强制刷新同一工作空间时只扫描一次 Skill", async () => {
|
||||
const writes: Array<Record<string, unknown>> = [];
|
||||
const child = { stdin: { write: (line: string) => (writes.push(JSON.parse(line)), true) } };
|
||||
const client = Reflect.construct(CodexAppClient, [child, () => undefined, emptyEventHistory]) as CodexAppClient;
|
||||
const testClient = client as unknown as TestClient;
|
||||
|
||||
const first = client.listSkills("D:\\site", true);
|
||||
const second = client.listSkills("D:\\site", true);
|
||||
assert.equal(writes.length, 1);
|
||||
testClient.handle({ id: writes[0].id, result: { data: [{ cwd: "D:\\site", skills: [], errors: [] }] } });
|
||||
assert.deepEqual(await Promise.all([first, second]), [
|
||||
{ data: [{ cwd: "D:\\site", skills: [], errors: [] }] },
|
||||
{ data: [{ cwd: "D:\\site", skills: [], errors: [] }] },
|
||||
]);
|
||||
|
||||
const next = client.listSkills("D:\\site", true);
|
||||
assert.equal(writes.length, 2);
|
||||
testClient.handle({ id: writes[1].id, result: { data: [] } });
|
||||
await next;
|
||||
});
|
||||
|
||||
test("显式 Skill 同时使用文本标记和结构化输入传给 turn/start", async () => {
|
||||
const writes: Array<Record<string, unknown>> = [];
|
||||
const child = { stdin: { write: (line: string) => (writes.push(JSON.parse(line)), true) } };
|
||||
const client = Reflect.construct(CodexAppClient, [child, () => undefined, emptyEventHistory]) as CodexAppClient;
|
||||
const testClient = client as unknown as TestClient;
|
||||
const skill = { name: "demo-skill", path: "D:\\site\\.agents\\skills\\demo-skill\\SKILL.md" };
|
||||
|
||||
const running = client.startTurn("thread-1", "执行任务", [], "request", undefined, undefined, undefined, skill);
|
||||
const request = writes.find((item) => item.method === "turn/start");
|
||||
assert.deepEqual((request?.params as { input?: unknown[] })?.input, [
|
||||
{ type: "text", text: "$demo-skill 执行任务", text_elements: [] },
|
||||
{ type: "skill", ...skill },
|
||||
]);
|
||||
testClient.handle({ id: request?.id, result: { turn: { id: "turn-1" } } });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
testClient.handleNotification("turn/completed", { threadId: "thread-1", turn: { id: "turn-1", status: "completed" } });
|
||||
await running;
|
||||
});
|
||||
|
||||
test("显式 Skill 不重复已有的文本标记", async () => {
|
||||
const writes: Array<Record<string, unknown>> = [];
|
||||
const child = { stdin: { write: (line: string) => (writes.push(JSON.parse(line)), true) } };
|
||||
const client = Reflect.construct(CodexAppClient, [child, () => undefined, emptyEventHistory]) as CodexAppClient;
|
||||
const testClient = client as unknown as TestClient;
|
||||
const skill = { name: "demo-skill", path: "D:\\site\\.agents\\skills\\demo-skill\\SKILL.md" };
|
||||
|
||||
const running = client.startTurn("thread-1", "Use $demo-skill: 执行任务", [], "request", undefined, undefined, undefined, skill);
|
||||
const request = writes.find((item) => item.method === "turn/start");
|
||||
assert.equal(((request?.params as { input?: Array<{ text?: string }> })?.input || [])[0]?.text, "Use $demo-skill: 执行任务");
|
||||
testClient.handle({ id: request?.id, result: { turn: { id: "turn-1" } } });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
testClient.handleNotification("turn/completed", { threadId: "thread-1", turn: { id: "turn-1", status: "completed" } });
|
||||
await running;
|
||||
});
|
||||
|
||||
test("skills/changed 作为站点级事件单独广播", () => {
|
||||
const events: Array<{ type: string; payload: unknown }> = [];
|
||||
const child = { stdin: { write: () => true } };
|
||||
const client = Reflect.construct(CodexAppClient, [child, (type: string, payload: unknown) => events.push({ type, payload }), emptyEventHistory]) as CodexAppClient;
|
||||
(client as unknown as TestClient).handleNotification("skills/changed", {});
|
||||
assert.deepEqual(events, [{ type: "skills_changed", payload: {} }]);
|
||||
});
|
||||
|
||||
test("turn/started 早于 turn/start 响应时保持完整事件归属", async () => {
|
||||
const writes: Array<Record<string, unknown>> = [];
|
||||
const events: Array<{ type: string; payload: unknown }> = [];
|
||||
@@ -274,7 +357,7 @@ test("turn 完成通知会保存本轮输入与终态 turn", async () => {
|
||||
const client = Reflect.construct(CodexAppClient, [child, () => undefined, history]) as CodexAppClient;
|
||||
const testClient = client as unknown as TestClient;
|
||||
|
||||
const running = client.startTurn("thread-1", "执行 Get-Location", [], "request");
|
||||
const running = client.startTurn("thread-1", "$command-runner 执行 Get-Location", [], "request", undefined, undefined, undefined, undefined, "执行 Get-Location");
|
||||
const request = writes.find((item) => item.method === "turn/start");
|
||||
assert.ok(request);
|
||||
testClient.handle({ id: request.id, result: { turn: { id: "turn-1" } } });
|
||||
@@ -282,7 +365,7 @@ test("turn 完成通知会保存本轮输入与终态 turn", async () => {
|
||||
testClient.handleNotification("turn/completed", { threadId: "thread-1", turn: { id: "turn-1", status: "completed", durationMs: 120 } });
|
||||
await running;
|
||||
|
||||
assert.deepEqual(persistedTurns, [{ threadId: "thread-1", turnId: "turn-1", turn: { id: "turn-1", status: "completed", durationMs: 120, input: "执行 Get-Location" } }]);
|
||||
assert.deepEqual(persistedTurns, [{ threadId: "thread-1", turnId: "turn-1", turn: { id: "turn-1", status: "completed", durationMs: 120, input: "$command-runner 执行 Get-Location", messageText: "执行 Get-Location" } }]);
|
||||
});
|
||||
|
||||
test("turn 完成状态会等待补充历史落盘后再广播", async () => {
|
||||
@@ -328,7 +411,7 @@ test("app-server 在 turn 完成通知前退出时保存失败终态", async ()
|
||||
const client = Reflect.construct(CodexAppClient, [child, (type: string, payload: unknown) => events.push({ type, payload }), history]) as CodexAppClient;
|
||||
const testClient = client as unknown as TestClient;
|
||||
|
||||
const running = client.startTurn("thread-1", "执行失败任务", [], "request");
|
||||
const running = client.startTurn("thread-1", "$failure-check 执行失败任务", [], "request", undefined, undefined, undefined, undefined, "执行失败任务");
|
||||
testClient.handle({ id: 1, result: { turn: { id: "turn-1" } } });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
testClient.failAll("Codex app-server exited: 1");
|
||||
@@ -337,7 +420,7 @@ test("app-server 在 turn 完成通知前退出时保存失败终态", async ()
|
||||
assert.deepEqual(persistedTurns, [{
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
turn: { id: "turn-1", status: "failed", error: { message: "Codex app-server exited: 1" }, input: "执行失败任务" },
|
||||
turn: { id: "turn-1", status: "failed", error: { message: "Codex app-server exited: 1" }, input: "$failure-check 执行失败任务", messageText: "执行失败任务" },
|
||||
}]);
|
||||
const completed = events.find((event) => event.type === "agent_event" && eventType(event.payload) === "turn.completed");
|
||||
assert.equal((completed?.payload as { status?: string })?.status, "failed");
|
||||
|
||||
@@ -8,16 +8,16 @@ import { VERSION } from "../config.js";
|
||||
import { logger } from "../utils/logger.js";
|
||||
import { field, type JsonRecord } from "../utils/value.js";
|
||||
import { codexEventHistory, type CodexEventHistory } from "./codex-event-history.js";
|
||||
import type { CodexNotificationParams, CodexPlanUpdate, CodexReasoningEffort, CodexRequestMethod, CodexRequestParams, CodexRequestResult, CodexTurnInput } from "./codex-protocol.js";
|
||||
import type { CodexNotificationParams, CodexPlanUpdate, CodexReasoningEffort, CodexRequestMethod, CodexRequestParams, CodexRequestResult, CodexSkillSelector, CodexTurnInput } from "./codex-protocol.js";
|
||||
import type { AgentEmit, AgentPermissionMode } from "./types.js";
|
||||
|
||||
type AgentEvent = JsonRecord & { type: string; usage?: unknown };
|
||||
type PendingRequest = { resolve: (value: unknown) => void; reject: (error: Error) => void };
|
||||
type ActiveTurn = PendingRequest & { threadId: string; turnId: string; prompt: string };
|
||||
type ActiveTurn = PendingRequest & { threadId: string; turnId: string; prompt: string; messageText?: string };
|
||||
type ItemDeltaParams = { threadId: string; turnId: string; itemId: string; delta: string; summaryIndex?: number };
|
||||
type PendingDelta = { delta: string; itemType: string; params: ItemDeltaParams; timer: ReturnType<typeof setTimeout> };
|
||||
type ApprovalRequest = { id: number; method: string; params: JsonRecord; decision?: string };
|
||||
type PendingTurnStart = { threadId: string; prompt: string; turnId?: string; onTurn?: (turnId: string) => void };
|
||||
type PendingTurnStart = { threadId: string; prompt: string; messageText?: string; turnId?: string; onTurn?: (turnId: string) => void };
|
||||
|
||||
const canvasAgentMcp = canvasAgentMcpCommand();
|
||||
const require = createRequire(import.meta.url);
|
||||
@@ -50,6 +50,7 @@ export class CodexAppClient {
|
||||
private plansByTurn = new Map<string, CodexPlanUpdate>();
|
||||
private approvalRequests = new Map<string, ApprovalRequest>();
|
||||
private finalizingTurns = new Map<string, Promise<void>>();
|
||||
private skillReloads = new Map<string, Promise<CodexRequestResult<"skills/list">>>();
|
||||
private failing = false;
|
||||
|
||||
/** 保存 app-server 子进程和事件出口。 */
|
||||
@@ -123,6 +124,26 @@ export class CodexAppClient {
|
||||
return this.request("model/list", { limit: 100, includeHidden: false });
|
||||
}
|
||||
|
||||
/** 查询指定工作空间可发现的 Codex Skills。 */
|
||||
listSkills(cwd: string, forceReload = false) {
|
||||
if (!forceReload) return this.request("skills/list", { cwds: [cwd] });
|
||||
const key = process.platform === "win32" ? path.resolve(cwd).toLowerCase() : path.resolve(cwd);
|
||||
const current = this.skillReloads.get(key);
|
||||
if (current) return current;
|
||||
const reload = this.request("skills/list", { cwds: [cwd], forceReload: true });
|
||||
this.skillReloads.set(key, reload);
|
||||
const clear = () => {
|
||||
if (this.skillReloads.get(key) === reload) this.skillReloads.delete(key);
|
||||
};
|
||||
void reload.then(clear, clear);
|
||||
return reload;
|
||||
}
|
||||
|
||||
/** 修改一个已发现 Skill 的启用状态。 */
|
||||
setSkillEnabled(path: string, enabled: boolean) {
|
||||
return this.request("skills/config/write", { path, enabled });
|
||||
}
|
||||
|
||||
/** 返回指定线程在当前进程中收到的最新任务计划。 */
|
||||
planUpdates(threadId: string) {
|
||||
return [...this.plansByTurn.values()].filter((item) => item.threadId === threadId);
|
||||
@@ -136,14 +157,14 @@ export class CodexAppClient {
|
||||
}
|
||||
|
||||
/** 启动一个 Codex turn 并等待完成通知。 */
|
||||
async startTurn(threadId: string, prompt: string, images: string[], permissionMode: AgentPermissionMode, model?: string, effort?: CodexReasoningEffort, onTurn?: (turnId: string) => void) {
|
||||
async startTurn(threadId: string, prompt: string, images: string[], permissionMode: AgentPermissionMode, model?: string, effort?: CodexReasoningEffort, onTurn?: (turnId: string) => void, skill?: CodexSkillSelector, messageText?: string) {
|
||||
this.currentThreadId = threadId;
|
||||
this.currentTurnId = "";
|
||||
this.lastUsage = null;
|
||||
const pendingStart: PendingTurnStart = { threadId, prompt, onTurn };
|
||||
const pendingStart: PendingTurnStart = { threadId, prompt, messageText, onTurn };
|
||||
this.pendingTurnStart = pendingStart;
|
||||
try {
|
||||
const { turn } = await this.request("turn/start", { threadId, input: codexInput(prompt, images), ...turnSettings(permissionMode), ...(model ? { model } : {}), ...(effort ? { effort } : {}) });
|
||||
const { turn } = await this.request("turn/start", { threadId, input: codexInput(prompt, images, skill), ...turnSettings(permissionMode), ...(model ? { model } : {}), ...(effort ? { effort } : {}) });
|
||||
const turnId = turn.id;
|
||||
if (!turnId) throw new Error("Codex app-server 没有返回 turn id");
|
||||
pendingStart.turnId = turnId;
|
||||
@@ -158,7 +179,7 @@ export class CodexAppClient {
|
||||
if (completed) throw completed;
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve, reject) => this.activeTurns.set(turnKey, { resolve, reject, threadId, turnId, prompt }));
|
||||
await new Promise((resolve, reject) => this.activeTurns.set(turnKey, { resolve, reject, threadId, turnId, prompt, messageText }));
|
||||
} catch (error) {
|
||||
if (!this.currentTurnId) this.currentThreadId = "";
|
||||
throw error;
|
||||
@@ -249,6 +270,10 @@ export class CodexAppClient {
|
||||
|
||||
/** 转换并广播 app-server 通知。 */
|
||||
private handleNotification(method: string, params: JsonRecord) {
|
||||
if (method === "skills/changed") {
|
||||
this.emit("skills_changed", {});
|
||||
return;
|
||||
}
|
||||
if (method === "serverRequest/resolved") {
|
||||
const requestId = String(field(params, "requestId") || "");
|
||||
const request = requestId ? this.approvalRequests.get(requestId) : undefined;
|
||||
@@ -347,8 +372,12 @@ export class CodexAppClient {
|
||||
const plan = this.plansByTurn.get(planKey);
|
||||
if (plan) this.plansByTurn.set(planKey, { ...plan, turnStatus: String(field(turn, "status") || "completed") });
|
||||
if (threadId && turnId) {
|
||||
const input = this.pendingTurnStart?.threadId === threadId ? this.pendingTurnStart.prompt : "";
|
||||
const turnRecord = { ...(turn && typeof turn === "object" && !Array.isArray(turn) ? turn as JsonRecord : { id: turnId, status: field(turn, "status") || "completed" }), ...(input ? { input } : {}) };
|
||||
const pendingStart = this.pendingTurnStart?.threadId === threadId && (!this.pendingTurnStart.turnId || this.pendingTurnStart.turnId === turnId) ? this.pendingTurnStart : undefined;
|
||||
const turnRecord = {
|
||||
...(turn && typeof turn === "object" && !Array.isArray(turn) ? turn as JsonRecord : { id: turnId, status: field(turn, "status") || "completed" }),
|
||||
...(pendingStart?.prompt ? { input: pendingStart.prompt } : {}),
|
||||
...(pendingStart?.messageText ? { messageText: pendingStart.messageText } : {}),
|
||||
};
|
||||
turnPersistence = this.eventHistory.recordTurn({ threadId, turnId, turn: turnRecord }).catch((error) => logger.warn("Failed to persist Codex turn history", { threadId, turnId, error }));
|
||||
this.finalizingTurns.set(planKey, turnPersistence);
|
||||
}
|
||||
@@ -491,18 +520,18 @@ export class CodexAppClient {
|
||||
if (this.failing) return;
|
||||
this.failing = true;
|
||||
this.approvalRequests.forEach((request, requestId) => this.emit("codex_approval_resolved", { ...request.params, requestId, decision: request.decision || "cancel" }));
|
||||
const failedTurns = new Map<string, { threadId: string; turnId: string; prompt: string }>();
|
||||
this.activeTurns.forEach(({ threadId, turnId, prompt }, key) => {
|
||||
if (!this.finalizingTurns.has(key)) failedTurns.set(key, { threadId, turnId, prompt });
|
||||
const failedTurns = new Map<string, { threadId: string; turnId: string; prompt: string; messageText?: string }>();
|
||||
this.activeTurns.forEach(({ threadId, turnId, prompt, messageText }, key) => {
|
||||
if (!this.finalizingTurns.has(key)) failedTurns.set(key, { threadId, turnId, prompt, messageText });
|
||||
});
|
||||
const pendingStart = this.pendingTurnStart;
|
||||
if (pendingStart?.turnId) {
|
||||
const key = turnCacheKey(pendingStart.threadId, pendingStart.turnId);
|
||||
if (!this.finalizingTurns.has(key) && !failedTurns.has(key)) failedTurns.set(key, { threadId: pendingStart.threadId, turnId: pendingStart.turnId, prompt: pendingStart.prompt });
|
||||
if (!this.finalizingTurns.has(key) && !failedTurns.has(key)) failedTurns.set(key, { threadId: pendingStart.threadId, turnId: pendingStart.turnId, prompt: pendingStart.prompt, messageText: pendingStart.messageText });
|
||||
}
|
||||
const finalizing = [...this.finalizingTurns.values()];
|
||||
const persistence = [...failedTurns.values()].map(({ threadId, turnId, prompt }) => {
|
||||
const turn = { id: turnId, status: "failed", error: { message }, ...(prompt ? { input: prompt } : {}) };
|
||||
const persistence = [...failedTurns.values()].map(({ threadId, turnId, prompt, messageText }) => {
|
||||
const turn = { id: turnId, status: "failed", error: { message }, ...(prompt ? { input: prompt } : {}), ...(messageText ? { messageText } : {}) };
|
||||
return this.eventHistory.recordTurn({ threadId, turnId, turn }).catch((historyError) => logger.warn("Failed to persist Codex turn failure", { threadId, turnId, error: historyError }));
|
||||
});
|
||||
const error = reported || failedTurns.size || finalizing.length ? new CodexReportedError(message) : new Error(message);
|
||||
@@ -522,8 +551,8 @@ export class CodexAppClient {
|
||||
this.currentThreadId = "";
|
||||
this.currentTurnId = "";
|
||||
void Promise.all([...finalizing, ...persistence]).then(() => {
|
||||
failedTurns.forEach(({ threadId, turnId, prompt }) => {
|
||||
const turn = { id: turnId, status: "failed", error: { message }, ...(prompt ? { input: prompt } : {}) };
|
||||
failedTurns.forEach(({ threadId, turnId, prompt, messageText }) => {
|
||||
const turn = { id: turnId, status: "failed", error: { message }, ...(prompt ? { input: prompt } : {}), ...(messageText ? { messageText } : {}) };
|
||||
this.emit("agent_event", { agent: "codex", type: "turn.completed", status: "failed", error: { message }, thread_id: threadId, turn_id: turnId, turn });
|
||||
this.emit("agent_done", { agent: "codex", status: "failed", error: { message }, thread_id: threadId, turn_id: turnId });
|
||||
});
|
||||
@@ -584,9 +613,18 @@ function turnSettings(permissionMode: AgentPermissionMode) {
|
||||
};
|
||||
}
|
||||
|
||||
/** 将文本和本地图片转换为 Codex turn 输入。 */
|
||||
function codexInput(prompt: string, images: string[]): CodexTurnInput[] {
|
||||
return [{ type: "text", text: prompt, text_elements: [] }, ...images.map<CodexTurnInput>((file) => ({ type: "localImage", path: file }))];
|
||||
/** 将文本、本地图片和显式 Skill 转换为 Codex turn 输入。 */
|
||||
function codexInput(prompt: string, images: string[], skill?: CodexSkillSelector): CodexTurnInput[] {
|
||||
const text = skill && !mentionsSkill(prompt, skill.name) ? `$${skill.name} ${prompt}` : prompt;
|
||||
return [
|
||||
{ type: "text", text, text_elements: [] },
|
||||
...images.map<CodexTurnInput>((file) => ({ type: "localImage", path: file })),
|
||||
...(skill ? [{ type: "skill", ...skill } as CodexTurnInput] : []),
|
||||
];
|
||||
}
|
||||
|
||||
function mentionsSkill(prompt: string, name: string) {
|
||||
return new RegExp(`\\$${name}(?![A-Za-z0-9_-]|:[A-Za-z0-9_-])`).test(prompt);
|
||||
}
|
||||
|
||||
/** 将 app-server 通知转换为前端使用的 Agent 事件。 */
|
||||
|
||||
@@ -85,6 +85,30 @@ test("用户消息使用与实时消息一致的 turn 级稳定 ID", () => {
|
||||
assert.equal(messages[0].itemId, "synthetic:user");
|
||||
});
|
||||
|
||||
test("显式用户正文优先于 Codex 自动补入的 Skill 前缀", () => {
|
||||
const messages = threadMessages({ id: "thread-1", turns: [{ id: "turn-1", status: "completed", items: [{
|
||||
id: "codex-user-1",
|
||||
type: "userMessage",
|
||||
content: [
|
||||
{ type: "text", text: "$product-grid 生成产品图" },
|
||||
{ type: "skill", name: "product-grid", path: "D:\\site\\.agents\\skills\\product-grid\\SKILL.md" },
|
||||
],
|
||||
}] }] }, [], { items: [], turns: [{ threadId: "thread-1", turnId: "turn-1", turn: { messageText: "生成产品图" } }] });
|
||||
assert.equal(messages[0].text, "生成产品图");
|
||||
});
|
||||
|
||||
test("没有显式用户正文时保留用户自行输入的 Skill 标记和正文", () => {
|
||||
const messages = threadMessages({ id: "thread-1", turns: [{ id: "turn-1", status: "completed", items: [{
|
||||
id: "codex-user-1",
|
||||
type: "userMessage",
|
||||
content: [
|
||||
{ type: "text", text: "$product-grid 生成产品图" },
|
||||
{ type: "skill", name: "product-grid", path: "D:\\site\\.agents\\skills\\product-grid\\SKILL.md" },
|
||||
],
|
||||
}] }] });
|
||||
assert.equal(messages[0].text, "$product-grid 生成产品图");
|
||||
});
|
||||
|
||||
test("Codex 历史省略命令时使用补充事件恢复完整命令卡片", () => {
|
||||
const messages = threadMessages({
|
||||
id: "thread-1",
|
||||
|
||||
@@ -62,8 +62,9 @@ export function threadMessages(thread: unknown, planUpdates: CodexPlanUpdate[] =
|
||||
let userAdded = false;
|
||||
let planAdded = false;
|
||||
let reasoningAdded = false;
|
||||
const fallbackUserText = displayUserText(String(field(turn, "input") || ""));
|
||||
if (fallbackUserText && !items.some((item) => field(item, "type") === "userMessage")) {
|
||||
const explicitUserText = displayUserText(String(field(turn, "messageText") || ""));
|
||||
const fallbackUserText = explicitUserText || displayUserText(String(field(turn, "input") || ""));
|
||||
if (fallbackUserText && (explicitUserText || !items.some((item) => field(item, "type") === "userMessage"))) {
|
||||
push({ id: "synthetic:user", role: "user", text: fallbackUserText });
|
||||
userAdded = true;
|
||||
if (planMessage) {
|
||||
@@ -458,7 +459,8 @@ function userFacingCodexError(message: string) {
|
||||
|
||||
/** 提取用户输入条目中的文本与附件占位信息。 */
|
||||
function userInputText(content: unknown) {
|
||||
return arrayValue(content)
|
||||
const items = arrayValue(content);
|
||||
const text = items
|
||||
.map((item) => {
|
||||
const type = String(field(item, "type") || "");
|
||||
if (type === "text") return String(field(item, "text") || "");
|
||||
@@ -468,6 +470,7 @@ function userInputText(content: unknown) {
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
return text.trim();
|
||||
}
|
||||
|
||||
/** 移除用户消息中由旧流程拼接的 Agent 前置提示词。 */
|
||||
|
||||
@@ -17,9 +17,35 @@ export type CodexModel = JsonRecord & {
|
||||
isDefault?: boolean;
|
||||
};
|
||||
|
||||
export type CodexSkillScope = "user" | "repo" | "system" | "admin";
|
||||
export type CodexSkillInterface = JsonRecord & {
|
||||
displayName?: string | null;
|
||||
shortDescription?: string | null;
|
||||
iconSmall?: string | null;
|
||||
iconLarge?: string | null;
|
||||
iconSmallUrl?: string | null;
|
||||
iconLargeUrl?: string | null;
|
||||
brandColor?: string | null;
|
||||
defaultPrompt?: string | null;
|
||||
};
|
||||
export type CodexSkillMetadata = JsonRecord & {
|
||||
name: string;
|
||||
description: string;
|
||||
shortDescription?: string | null;
|
||||
interface?: CodexSkillInterface | null;
|
||||
dependencies?: JsonRecord | null;
|
||||
path: string;
|
||||
scope: CodexSkillScope;
|
||||
enabled: boolean;
|
||||
};
|
||||
export type CodexSkillError = { path: string; message: string };
|
||||
export type CodexSkillsListEntry = { cwd: string; skills: CodexSkillMetadata[]; errors: CodexSkillError[] };
|
||||
export type CodexSkillSelector = { name: string; path: string };
|
||||
|
||||
export type CodexTurnInput =
|
||||
| { type: "text"; text: string; text_elements: [] }
|
||||
| { type: "localImage"; path: string };
|
||||
| { type: "localImage"; path: string }
|
||||
| ({ type: "skill" } & CodexSkillSelector);
|
||||
|
||||
type ThreadOptions = {
|
||||
approvalPolicy: "never" | "on-request";
|
||||
@@ -67,6 +93,14 @@ type CodexRequestSpec = {
|
||||
params: { limit: number; includeHidden: boolean };
|
||||
result: { data: CodexModel[]; nextCursor: string | null };
|
||||
};
|
||||
"skills/list": {
|
||||
params: { cwds: string[]; forceReload?: boolean };
|
||||
result: { data: CodexSkillsListEntry[] };
|
||||
};
|
||||
"skills/config/write": {
|
||||
params: { path?: string | null; name?: string | null; enabled: boolean };
|
||||
result: { effectiveEnabled: boolean };
|
||||
};
|
||||
"turn/start": {
|
||||
params: { threadId: string; input: CodexTurnInput[]; approvalPolicy: "never" | "on-request"; sandboxPolicy: { type: "workspaceWrite"; networkAccess: boolean } | { type: "dangerFullAccess" }; model?: string; effort?: CodexReasoningEffort };
|
||||
result: { turn: CodexTurn };
|
||||
@@ -101,6 +135,7 @@ type CodexNotificationSpec = {
|
||||
"item/commandExecution/outputDelta": { threadId: string; turnId: string; itemId: string; delta: string };
|
||||
"thread/tokenUsage/updated": { threadId: string; turnId: string; tokenUsage: { last: TokenUsageBreakdown } };
|
||||
"mcpServer/startupStatus/updated": CodexMcpStartupStatus;
|
||||
"skills/changed": Record<string, never>;
|
||||
error: { threadId: string; turnId: string; error: CodexTurnError; willRetry: boolean };
|
||||
};
|
||||
|
||||
|
||||
@@ -7,10 +7,17 @@ import { errorMessage, field } from "../utils/value.js";
|
||||
import { CodexAppClient, CodexReportedError } from "./codex-client.js";
|
||||
import { codexEventHistory } from "./codex-event-history.js";
|
||||
import { settledTurnIds, summarizeCodexThread, threadMessages } from "./codex-history.js";
|
||||
import type { CodexReasoningEffort } from "./codex-protocol.js";
|
||||
import type { CodexReasoningEffort, CodexSkillMetadata, CodexSkillSelector, CodexSkillsListEntry } from "./codex-protocol.js";
|
||||
import type { AgentAttachment, AgentEmit, AgentPermissionMode } from "./types.js";
|
||||
|
||||
type CodexRunOptions = { threadId?: string; cwd?: string; permissionMode?: AgentPermissionMode; model?: string; effort?: CodexReasoningEffort; appEmit?: AgentEmit; onStart?: () => void; onThread?: (threadId: string) => void; onTurn?: (turnId: string) => void; onFinish?: () => void };
|
||||
type CodexRunOptions = { threadId?: string; cwd?: string; permissionMode?: AgentPermissionMode; model?: string; effort?: CodexReasoningEffort; skill?: CodexSkillSelector; messageText?: string; appEmit?: AgentEmit; onStart?: () => void; onThread?: (threadId: string) => void; onTurn?: (turnId: string) => void; onFinish?: () => void };
|
||||
|
||||
export class CodexSkillLookupError extends Error {
|
||||
override name = "CodexSkillLookupError";
|
||||
constructor(message: string, readonly statusCode: 400 | 404 | 409) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
let codexQueue: Promise<unknown> = Promise.resolve();
|
||||
let codexApp: CodexAppClient | null = null;
|
||||
@@ -75,6 +82,31 @@ export async function listCodexModels(emit: AgentEmit) {
|
||||
return await (await getCodexApp(emit)).listModels();
|
||||
}
|
||||
|
||||
/** 查询当前工作空间的原生 Skill 列表。 */
|
||||
export async function listCodexSkills(emit: AgentEmit, cwd: string, forceReload = false): Promise<CodexSkillsListEntry> {
|
||||
const result = await (await getCodexApp(emit)).listSkills(cwd, forceReload);
|
||||
return result.data.find((entry) => samePath(entry.cwd, cwd)) || { cwd, skills: [], errors: [] };
|
||||
}
|
||||
|
||||
/** 从原生 Skill 列表中解析并校验浏览器提交的选择器。 */
|
||||
export async function resolveCodexSkill(emit: AgentEmit, cwd: string, selector: CodexSkillSelector, requireEnabled = false): Promise<CodexSkillMetadata> {
|
||||
const name = String(selector?.name || "");
|
||||
const requestedPath = String(selector?.path || "");
|
||||
if (!name || !requestedPath || !path.isAbsolute(requestedPath)) throw new CodexSkillLookupError("Skill 选择无效", 400);
|
||||
const { skills } = await listCodexSkills(emit, cwd, true);
|
||||
const skill = skills.find((item) => item.name === name && samePath(item.path, requestedPath));
|
||||
if (!skill) throw new CodexSkillLookupError("找不到指定 Skill,请刷新列表后重试", 404);
|
||||
if (requireEnabled && !skill.enabled) throw new CodexSkillLookupError("该 Skill 已停用,请先启用后再使用", 409);
|
||||
return skill;
|
||||
}
|
||||
|
||||
/** 修改经过原生列表校验的 Skill 启用状态。 */
|
||||
export async function configureCodexSkill(emit: AgentEmit, cwd: string, selector: CodexSkillSelector, enabled: boolean) {
|
||||
const skill = await resolveCodexSkill(emit, cwd, selector);
|
||||
const result = await (await getCodexApp(emit)).setSkillEnabled(skill.path, enabled);
|
||||
return { ...result, skill: { ...skill, enabled: result.effectiveEnabled } };
|
||||
}
|
||||
|
||||
/** 读取指定 Codex 线程及其聊天历史。 */
|
||||
export async function readCodexThread(emit: AgentEmit, threadId: string, cwd?: string) {
|
||||
const app = await getCodexApp(emit);
|
||||
@@ -113,14 +145,14 @@ async function runCodexTurnNow(prompt: string, lifecycleEmit: AgentEmit, attachm
|
||||
let threadId = await ensureCodexThread(app, options, lifecycleEmit);
|
||||
options.onThread?.(threadId);
|
||||
try {
|
||||
await app.startTurn(threadId, prompt, files, options.permissionMode || "request", options.model, options.effort, options.onTurn);
|
||||
await app.startTurn(threadId, prompt, files, options.permissionMode || "request", options.model, options.effort, options.onTurn, options.skill, options.messageText);
|
||||
} catch (error) {
|
||||
if (!isRecoverableThreadError(error)) throw error;
|
||||
lifecycleEmit("agent_log", { text: `Codex thread unavailable, starting a new thread: ${errorMessage(error)}` });
|
||||
loadedThreadId = "";
|
||||
threadId = await ensureCodexThread(app, { cwd: options.cwd }, lifecycleEmit);
|
||||
options.onThread?.(threadId);
|
||||
await app.startTurn(threadId, prompt, files, options.permissionMode || "request", options.model, options.effort, options.onTurn);
|
||||
await app.startTurn(threadId, prompt, files, options.permissionMode || "request", options.model, options.effort, options.onTurn, options.skill, options.messageText);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Codex turn failed", error);
|
||||
@@ -210,7 +242,13 @@ function assertThreadWorkspace(thread: unknown, cwd?: string) {
|
||||
/** 判断线程工作目录是否与当前工作空间一致。 */
|
||||
function threadInWorkspace(thread: unknown, cwd: string) {
|
||||
const threadCwd = String(field(thread, "cwd") || "");
|
||||
return Boolean(threadCwd && path.resolve(threadCwd) === path.resolve(cwd));
|
||||
return Boolean(threadCwd && samePath(threadCwd, cwd));
|
||||
}
|
||||
|
||||
/** 比较跨平台绝对路径;Windows 路径不区分大小写。 */
|
||||
function samePath(left: string, right: string) {
|
||||
const normalize = (value: string) => process.platform === "win32" ? path.resolve(value).toLowerCase() : path.resolve(value);
|
||||
return normalize(left) === normalize(right);
|
||||
}
|
||||
|
||||
/** 将图片附件写入临时文件供 Codex 读取。 */
|
||||
|
||||
@@ -202,7 +202,7 @@ test("new clients receive the current Codex state and later updates", (t) => {
|
||||
t.after(() => client.close());
|
||||
|
||||
const hello = client.event("hello");
|
||||
assert.equal(field(hello, "protocolVersion"), 3);
|
||||
assert.equal(field(hello, "protocolVersion"), 4);
|
||||
assert.deepEqual(field(hello, "workspace"), { activeThreadId: "thread-2" });
|
||||
assert.deepEqual(field(hello, "codex"), { busy: true, threadId: "thread-2", turnId: "turn-1" });
|
||||
assert.deepEqual(field(hello, "pendingApprovals"), [{ requestId: "approval-1", threadId: "thread-2" }]);
|
||||
|
||||
@@ -12,7 +12,7 @@ type PendingRequest = { clientId: string; resolve: (value: unknown) => void; rej
|
||||
type TurnAttachment = { clientId: string; id: string; name: string; type: string; size: number; width: number; height: number; dataUrl: string };
|
||||
type ReplayEvent = { type: string; payload: Record<string, unknown> };
|
||||
export type CodexState = { busy: boolean; threadId: string; turnId: string };
|
||||
export const AGENT_PROTOCOL_VERSION = 3;
|
||||
export const AGENT_PROTOCOL_VERSION = 4;
|
||||
|
||||
const SITE_TOOLS = new Set<ToolName>([
|
||||
"site_navigate",
|
||||
|
||||
@@ -4,13 +4,14 @@ import path from "node:path";
|
||||
import express, { type NextFunction, type Request, type Response } from "express";
|
||||
|
||||
import { runClaudeTurn } from "../agent/claude.js";
|
||||
import { archiveCodexThread, interruptCodexTurn, listCodexModels, listCodexThreads, readCodexThread, resolveCodexApproval, resumeCodexThread, runCodexTurn, startCodexThread, summarizeCodexThread } from "../agent/codex.js";
|
||||
import type { CodexReasoningEffort } from "../agent/codex-protocol.js";
|
||||
import { archiveCodexThread, CodexSkillLookupError, configureCodexSkill, interruptCodexTurn, listCodexModels, listCodexSkills, listCodexThreads, readCodexThread, resolveCodexApproval, resolveCodexSkill, resumeCodexThread, runCodexTurn, startCodexThread, summarizeCodexThread } from "../agent/codex.js";
|
||||
import type { CodexReasoningEffort, CodexSkillSelector } from "../agent/codex-protocol.js";
|
||||
import type { AgentAttachment, AgentPermissionMode } from "../agent/types.js";
|
||||
import { AGENT_PROTOCOL_VERSION, CanvasSession } from "../canvas/session.js";
|
||||
import { DEFAULT_PORT, ensureSiteWorkspace, loadConfig, saveConfig, updateSiteWorkspace, type CanvasAgentConfig } from "../config.js";
|
||||
import { logger } from "../utils/logger.js";
|
||||
import { checkVersions } from "../version-check.js";
|
||||
import { SkillStore, SkillStoreError } from "../skills/store.js";
|
||||
|
||||
/** 启动仅监听本机的 Canvas Agent HTTP 服务。 */
|
||||
export function startHttpServer() {
|
||||
@@ -20,10 +21,15 @@ export function startHttpServer() {
|
||||
saveConfig(config);
|
||||
|
||||
const session = new CanvasSession();
|
||||
const skillStore = new SkillStore(ensureSiteWorkspace(config).workspacePath);
|
||||
/** 将 Agent 事件广播到所属线程或全部网页。 */
|
||||
const emit = (type: string, payload: unknown) => {
|
||||
const scope = session.codexBusy ? session.codexEventScope : { threadId: "", turnId: "", sourceClientId: "" };
|
||||
const value = payload && typeof payload === "object" && !Array.isArray(payload) ? payload as Record<string, unknown> : { value: payload };
|
||||
if (type === "skills_changed") {
|
||||
session.emitAll(type, value);
|
||||
return;
|
||||
}
|
||||
const scope = session.codexBusy ? session.codexEventScope : { threadId: "", turnId: "", sourceClientId: "" };
|
||||
const threadId = String(value.threadId || value.thread_id || scope.threadId || ensureSiteWorkspace(config).activeThreadId || "");
|
||||
const turnId = String(value.turnId || value.turn_id || scope.turnId || "");
|
||||
const sourceClientId = String(value.sourceClientId || scope.sourceClientId || "");
|
||||
@@ -129,6 +135,38 @@ export function startHttpServer() {
|
||||
res.json({ ok: true, workspace });
|
||||
});
|
||||
app.get("/agent/codex/models", route(async (_req, res) => res.json({ ok: true, ...(await listCodexModels(emit)) })));
|
||||
app.get("/agent/codex/skills", route(async (req, res) => {
|
||||
const workspace = ensureSiteWorkspace(config);
|
||||
const result = await listCodexSkills(emit, workspace.workspacePath, String(req.query.forceReload || "") === "1");
|
||||
res.json({ ok: true, data: result.skills.map((skill) => ({ ...skill, managed: skillStore.isManagedPath(skill.path) })), errors: result.errors });
|
||||
}));
|
||||
app.get("/agent/codex/skills/:name", route(async (req, res) => {
|
||||
res.json({ ok: true, data: await skillStore.get(routeParam(req.params.name)) });
|
||||
}));
|
||||
app.post("/agent/codex/skills", route(async (req, res) => {
|
||||
const data = await skillStore.create(req.body);
|
||||
session.emitAll("skills_changed", { forceReload: true });
|
||||
res.status(201).json({ ok: true, data });
|
||||
}));
|
||||
app.post("/agent/codex/skills/:name/enabled", route(async (req, res) => {
|
||||
if (typeof req.body?.enabled !== "boolean") return res.status(400).json({ ok: false, error: "Skill 启用状态无效" });
|
||||
const workspace = ensureSiteWorkspace(config);
|
||||
const selector = skillSelector(req.body);
|
||||
if (selector.name !== routeParam(req.params.name)) return res.status(400).json({ ok: false, error: "Skill 选择无效" });
|
||||
const data = await configureCodexSkill(emit, workspace.workspacePath, selector, req.body.enabled);
|
||||
session.emitAll("skills_changed", { forceReload: true });
|
||||
res.json({ ok: true, data });
|
||||
}));
|
||||
app.post("/agent/codex/skills/:name/delete", route(async (req, res) => {
|
||||
await skillStore.delete(routeParam(req.params.name), String(req.body?.expectedRevision || ""));
|
||||
session.emitAll("skills_changed", { forceReload: true });
|
||||
res.json({ ok: true });
|
||||
}));
|
||||
app.post("/agent/codex/skills/:name", route(async (req, res) => {
|
||||
const data = await skillStore.update(routeParam(req.params.name), req.body);
|
||||
session.emitAll("skills_changed", { forceReload: true });
|
||||
res.json({ ok: true, data });
|
||||
}));
|
||||
app.get("/agent/codex/threads", route(async (req, res) => {
|
||||
const workspace = ensureSiteWorkspace(config);
|
||||
const result = await listCodexThreads(emit, { cwd: workspace.workspacePath, searchTerm: String(req.query.searchTerm || "") });
|
||||
@@ -184,6 +222,7 @@ export function startHttpServer() {
|
||||
if (requestedThreadId !== activeThreadId) return res.status(409).json({ ok: false, error: "当前会话已在其他页面切换,请同步后重试" });
|
||||
const model = String(req.body?.model || "") || undefined;
|
||||
const effort = reasoningEffort(req.body?.effort);
|
||||
const skill = req.body?.skill === undefined ? undefined : await resolveCodexSkill(emit, workspace.workspacePath, skillSelector(req.body.skill), true);
|
||||
const messageId = String(req.body?.messageId || Date.now());
|
||||
const messageText = String(req.body?.messageText || prompt || `发送了 ${attachments.length} 张图片`);
|
||||
let threadId = activeThreadId;
|
||||
@@ -224,6 +263,8 @@ export function startHttpServer() {
|
||||
permissionMode: permissionMode(req.body?.permissionMode),
|
||||
model,
|
||||
effort,
|
||||
...(skill ? { skill: { name: skill.name, path: skill.path } } : {}),
|
||||
messageText,
|
||||
appEmit: emit,
|
||||
onStart: () => session.bindClient(clientId),
|
||||
onThread: (actualThreadId) => {
|
||||
@@ -293,6 +334,7 @@ export function startHttpServer() {
|
||||
app.use((_req, res) => res.status(404).json({ ok: false, error: "not found" }));
|
||||
app.use((error: Error, req: Request, res: Response, _next: NextFunction) => {
|
||||
logger.error("HTTP request failed", { method: req.method, path: req.path, error });
|
||||
if (error instanceof SkillStoreError || error instanceof CodexSkillLookupError) return void res.status(error.statusCode).json({ ok: false, error: error.message });
|
||||
res.status(500).json({ ok: false, error: error.message });
|
||||
});
|
||||
|
||||
@@ -327,6 +369,15 @@ function reasoningEffort(value: unknown): CodexReasoningEffort | undefined {
|
||||
return value === "minimal" || value === "low" || value === "medium" || value === "high" || value === "xhigh" || value === "max" || value === "ultra" ? value : undefined;
|
||||
}
|
||||
|
||||
/** 读取浏览器提交的 Skill 选择器;真实路径随后必须通过原生列表校验。 */
|
||||
function skillSelector(value: unknown): CodexSkillSelector {
|
||||
const selector = value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
const name = typeof selector.name === "string" ? selector.name : "";
|
||||
const skillPath = typeof selector.path === "string" ? selector.path : "";
|
||||
if (!name || !skillPath) throw new CodexSkillLookupError("Skill 选择无效", 400);
|
||||
return { name, path: skillPath };
|
||||
}
|
||||
|
||||
/** 使用当前操作系统的文件管理器定位本地文件。 */
|
||||
function revealLocalFile(filePath: string, isDirectory: boolean) {
|
||||
const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer.exe" : "xdg-open";
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
import { SkillStore, SkillStoreError } from "./store.js";
|
||||
|
||||
test("创建、读取和更新画布专属 Skill", async (context) => {
|
||||
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), "canvas-skill-store-"));
|
||||
context.after(() => fs.rm(workspace, { recursive: true, force: true }));
|
||||
const store = new SkillStore(workspace);
|
||||
|
||||
const created = await store.create({
|
||||
name: "product-grid",
|
||||
description: "生成商品九宫格",
|
||||
instructions: "根据商品信息创建九宫格生成流程。",
|
||||
interface: {
|
||||
displayName: "产品九宫格生成",
|
||||
shortDescription: "根据商品资料与参考图自动规划并生成产品图片九宫格流程",
|
||||
defaultPrompt: "Use $product-grid to build a product image grid.",
|
||||
},
|
||||
});
|
||||
assert.equal(created.managed, true);
|
||||
assert.equal(created.interface?.displayName, "产品九宫格生成");
|
||||
assert.equal(created.revision.length, 64);
|
||||
assert.equal(store.isManagedPath(created.path), true);
|
||||
const openAi = await fs.readFile(path.join(path.dirname(created.path), "agents", "openai.yaml"), "utf8");
|
||||
assert.match(openAi, /^interface:/m);
|
||||
assert.match(openAi, /display_name: "产品九宫格生成"/);
|
||||
|
||||
const updated = await store.update("product-grid", {
|
||||
description: "生成商品图片九宫格",
|
||||
instructions: "先分析商品,再创建九宫格生成流程。",
|
||||
interface: { displayName: "产品九宫格生成" },
|
||||
expectedRevision: created.revision,
|
||||
});
|
||||
assert.equal(updated.description, "生成商品图片九宫格");
|
||||
assert.notEqual(updated.revision, created.revision);
|
||||
assert.deepEqual(updated.interface, { displayName: "产品九宫格生成" });
|
||||
});
|
||||
|
||||
test("revision 不匹配时拒绝覆盖或删除", async (context) => {
|
||||
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), "canvas-skill-store-"));
|
||||
context.after(() => fs.rm(workspace, { recursive: true, force: true }));
|
||||
const store = new SkillStore(workspace);
|
||||
const created = await store.create({ name: "demo", description: "演示 Skill", instructions: "执行演示流程。" });
|
||||
|
||||
await assert.rejects(store.update("demo", {
|
||||
description: "已过期的修改",
|
||||
instructions: "不会写入。",
|
||||
expectedRevision: "0".repeat(64),
|
||||
}), (error: unknown) => error instanceof SkillStoreError && error.statusCode === 409);
|
||||
await assert.rejects(store.delete("demo", "0".repeat(64)), (error: unknown) => error instanceof SkillStoreError && error.statusCode === 409);
|
||||
assert.equal((await store.get("demo")).revision, created.revision);
|
||||
});
|
||||
|
||||
test("清空界面字段时保留未由画布管理的 openai 元数据", async (context) => {
|
||||
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), "canvas-skill-store-"));
|
||||
context.after(() => fs.rm(workspace, { recursive: true, force: true }));
|
||||
const store = new SkillStore(workspace);
|
||||
const created = await store.create({
|
||||
name: "metadata-demo",
|
||||
description: "演示界面元数据",
|
||||
instructions: "执行演示流程。",
|
||||
interface: { displayName: "演示", defaultPrompt: "Use $metadata-demo to run the demo." },
|
||||
});
|
||||
const openAiFile = path.join(path.dirname(created.path), "agents", "openai.yaml");
|
||||
await fs.writeFile(openAiFile, "interface:\n display_name: 演示\n default_prompt: Use $metadata-demo to run the demo.\n icon_small: ./assets/icon.png\n brand_color: '#336699'\n", "utf8");
|
||||
const current = await store.get("metadata-demo");
|
||||
|
||||
const updated = await store.update("metadata-demo", {
|
||||
description: current.description,
|
||||
instructions: current.instructions,
|
||||
interface: null,
|
||||
expectedRevision: current.revision,
|
||||
});
|
||||
const openAi = await fs.readFile(openAiFile, "utf8");
|
||||
assert.equal(updated.interface, undefined);
|
||||
assert.match(openAi, /icon_small:/);
|
||||
assert.match(openAi, /brand_color:/);
|
||||
assert.doesNotMatch(openAi, /display_name:|default_prompt:/);
|
||||
});
|
||||
|
||||
test("名称、正文大小和默认提示词均经过校验", async (context) => {
|
||||
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), "canvas-skill-store-"));
|
||||
context.after(() => fs.rm(workspace, { recursive: true, force: true }));
|
||||
const store = new SkillStore(workspace);
|
||||
|
||||
await assert.rejects(store.create({ name: "Invalid_Name", description: "无效", instructions: "无效" }), (error: unknown) => error instanceof SkillStoreError && error.statusCode === 400);
|
||||
await assert.rejects(store.create({ name: "invalid-description", description: "包含 <标签>", instructions: "无效" }), /尖括号/);
|
||||
await assert.rejects(store.create({ name: "too-large", description: "过大", instructions: "中".repeat(90_000) }), /256KiB/);
|
||||
await assert.rejects(store.create({
|
||||
name: "prompt-check",
|
||||
description: "校验默认提示词",
|
||||
instructions: "执行流程。",
|
||||
interface: { defaultPrompt: "Use this skill." },
|
||||
}), /\$prompt-check/);
|
||||
await assert.rejects(store.create({
|
||||
name: "prompt-check",
|
||||
description: "校验默认提示词边界",
|
||||
instructions: "执行流程。",
|
||||
interface: { defaultPrompt: "Use $prompt-check-extra instead." },
|
||||
}), /\$prompt-check/);
|
||||
await assert.rejects(store.create({
|
||||
name: "prompt-check",
|
||||
description: "校验命名空间提示词边界",
|
||||
instructions: "执行流程。",
|
||||
interface: { defaultPrompt: "Use $prompt-check:other instead." },
|
||||
}), /\$prompt-check/);
|
||||
await assert.rejects(store.create({
|
||||
name: "short-description-check",
|
||||
description: "校验卡片短说明长度",
|
||||
instructions: "执行流程。",
|
||||
interface: { shortDescription: "过短" },
|
||||
}), /不能少于 25 个字符/);
|
||||
await assert.rejects(store.create({
|
||||
name: "prompt-check",
|
||||
description: "校验默认提示词大小写",
|
||||
instructions: "执行流程。",
|
||||
interface: { defaultPrompt: "Use $PROMPT-CHECK instead." },
|
||||
}), /\$prompt-check/);
|
||||
await assert.rejects(store.create({
|
||||
name: "invalid-interface",
|
||||
description: "校验界面元数据",
|
||||
instructions: "执行流程。",
|
||||
interface: false as never,
|
||||
}), /界面元数据无效/);
|
||||
});
|
||||
|
||||
test("更新校验失败时不会提前改写 SKILL.md", async (context) => {
|
||||
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), "canvas-skill-store-"));
|
||||
context.after(() => fs.rm(workspace, { recursive: true, force: true }));
|
||||
const store = new SkillStore(workspace);
|
||||
const created = await store.create({ name: "safe-update", description: "保留原内容", instructions: "执行原流程。" });
|
||||
|
||||
await assert.rejects(store.update("safe-update", {
|
||||
description: "不应写入",
|
||||
instructions: "不应写入。",
|
||||
interface: { defaultPrompt: "缺少 Skill 名称" },
|
||||
expectedRevision: created.revision,
|
||||
}), /\$safe-update/);
|
||||
assert.deepEqual(await store.get("safe-update"), created);
|
||||
});
|
||||
|
||||
test("拒绝读取非对象格式的 openai.yaml", async (context) => {
|
||||
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), "canvas-skill-store-"));
|
||||
context.after(() => fs.rm(workspace, { recursive: true, force: true }));
|
||||
const store = new SkillStore(workspace);
|
||||
const created = await store.create({ name: "invalid-metadata", description: "校验元数据", instructions: "执行流程。" });
|
||||
const agentsDir = path.join(path.dirname(created.path), "agents");
|
||||
await fs.mkdir(agentsDir);
|
||||
await fs.writeFile(path.join(agentsDir, "openai.yaml"), "- invalid\n", "utf8");
|
||||
|
||||
await assert.rejects(store.get("invalid-metadata"), (error: unknown) => error instanceof SkillStoreError && error.statusCode === 409);
|
||||
});
|
||||
|
||||
test("不完整更新请求返回校验错误而不是运行时异常", async (context) => {
|
||||
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), "canvas-skill-store-"));
|
||||
context.after(() => fs.rm(workspace, { recursive: true, force: true }));
|
||||
const store = new SkillStore(workspace);
|
||||
|
||||
await assert.rejects(store.update("demo", undefined as never), (error: unknown) => error instanceof SkillStoreError && error.statusCode === 400);
|
||||
});
|
||||
|
||||
test("空 interface 元数据读取为空", async (context) => {
|
||||
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), "canvas-skill-store-"));
|
||||
context.after(() => fs.rm(workspace, { recursive: true, force: true }));
|
||||
const store = new SkillStore(workspace);
|
||||
const created = await store.create({ name: "empty-interface", description: "空界面元数据", instructions: "执行流程。" });
|
||||
const agentsDir = path.join(path.dirname(created.path), "agents");
|
||||
await fs.mkdir(agentsDir);
|
||||
await fs.writeFile(path.join(agentsDir, "openai.yaml"), "interface: {}\n", "utf8");
|
||||
|
||||
assert.equal((await store.get("empty-interface")).interface, undefined);
|
||||
});
|
||||
|
||||
test("读取或修改不存在的 Skill 不会创建目录", async (context) => {
|
||||
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), "canvas-skill-store-"));
|
||||
context.after(() => fs.rm(workspace, { recursive: true, force: true }));
|
||||
const store = new SkillStore(workspace);
|
||||
const skillsRoot = path.join(workspace, ".agents", "skills");
|
||||
|
||||
await assert.rejects(store.get("missing"), (error: unknown) => error instanceof SkillStoreError && error.statusCode === 404);
|
||||
await assert.rejects(store.update("missing", {
|
||||
description: "不存在",
|
||||
instructions: "不会写入。",
|
||||
expectedRevision: "0".repeat(64),
|
||||
}), (error: unknown) => error instanceof SkillStoreError && error.statusCode === 404);
|
||||
await assert.rejects(store.delete("missing", "0".repeat(64)), (error: unknown) => error instanceof SkillStoreError && error.statusCode === 404);
|
||||
await assert.rejects(fs.access(skillsRoot));
|
||||
});
|
||||
|
||||
test("更新后只保留完整文件且不遗留临时文件", async (context) => {
|
||||
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), "canvas-skill-store-"));
|
||||
context.after(() => fs.rm(workspace, { recursive: true, force: true }));
|
||||
const store = new SkillStore(workspace);
|
||||
const created = await store.create({
|
||||
name: "atomic-update",
|
||||
description: "验证原子更新",
|
||||
instructions: "执行旧流程。",
|
||||
interface: { displayName: "原子更新" },
|
||||
});
|
||||
|
||||
const updated = await store.update("atomic-update", {
|
||||
description: "验证完整更新",
|
||||
instructions: "执行新流程。",
|
||||
interface: { displayName: "完整更新" },
|
||||
expectedRevision: created.revision,
|
||||
});
|
||||
const skillDirectory = path.dirname(updated.path);
|
||||
const skillText = await fs.readFile(updated.path, "utf8");
|
||||
const openAiText = await fs.readFile(path.join(skillDirectory, "agents", "openai.yaml"), "utf8");
|
||||
assert.match(skillText, /description: 验证完整更新/);
|
||||
assert.match(skillText, /执行新流程/);
|
||||
assert.match(openAiText, /display_name: "完整更新"/);
|
||||
assert.deepEqual((await fs.readdir(skillDirectory)).filter((name) => name.endsWith(".tmp")), []);
|
||||
assert.deepEqual((await fs.readdir(path.join(skillDirectory, "agents"))).filter((name) => name.endsWith(".tmp")), []);
|
||||
});
|
||||
|
||||
test("拒绝读取非对象格式的 interface 元数据", async (context) => {
|
||||
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), "canvas-skill-store-"));
|
||||
context.after(() => fs.rm(workspace, { recursive: true, force: true }));
|
||||
const store = new SkillStore(workspace);
|
||||
const created = await store.create({ name: "invalid-interface", description: "校验界面元数据", instructions: "执行流程。" });
|
||||
const agentsDir = path.join(path.dirname(created.path), "agents");
|
||||
await fs.mkdir(agentsDir);
|
||||
await fs.writeFile(path.join(agentsDir, "openai.yaml"), "interface: false\n", "utf8");
|
||||
|
||||
await assert.rejects(store.get("invalid-interface"), (error: unknown) => error instanceof SkillStoreError && error.statusCode === 409);
|
||||
});
|
||||
@@ -0,0 +1,439 @@
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import matter from "gray-matter";
|
||||
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
|
||||
|
||||
const NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
||||
const MAX_NAME_LENGTH = 64;
|
||||
const MAX_DESCRIPTION_LENGTH = 1024;
|
||||
const MAX_INSTRUCTIONS_BYTES = 256 * 1024;
|
||||
const MAX_DISPLAY_NAME_LENGTH = 64;
|
||||
const MIN_SHORT_DESCRIPTION_LENGTH = 25;
|
||||
const MAX_SHORT_DESCRIPTION_LENGTH = 64;
|
||||
const MAX_DEFAULT_PROMPT_LENGTH = 1024;
|
||||
|
||||
export type ManagedSkillInterface = {
|
||||
displayName?: string;
|
||||
shortDescription?: string;
|
||||
defaultPrompt?: string;
|
||||
};
|
||||
|
||||
export type ManagedSkillDetail = {
|
||||
name: string;
|
||||
description: string;
|
||||
instructions: string;
|
||||
interface?: ManagedSkillInterface;
|
||||
path: string;
|
||||
revision: string;
|
||||
managed: true;
|
||||
};
|
||||
|
||||
export type CreateManagedSkillInput = {
|
||||
name: string;
|
||||
description: string;
|
||||
instructions: string;
|
||||
interface?: ManagedSkillInterface | null;
|
||||
};
|
||||
|
||||
export type UpdateManagedSkillInput = {
|
||||
description: string;
|
||||
instructions: string;
|
||||
interface?: ManagedSkillInterface | null;
|
||||
expectedRevision: string;
|
||||
};
|
||||
|
||||
type SkillDocument = {
|
||||
raw: string;
|
||||
frontmatter: Record<string, unknown>;
|
||||
description: string;
|
||||
instructions: string;
|
||||
};
|
||||
|
||||
type OpenAiDocument = { raw: string; data: Record<string, unknown>; interface?: ManagedSkillInterface };
|
||||
|
||||
export class SkillStoreError extends Error {
|
||||
override name = "SkillStoreError";
|
||||
constructor(message: string, readonly statusCode: 400 | 404 | 409 | 500) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
/** 只管理站点工作空间下 `.agents/skills` 中的画布专属 Skill。 */
|
||||
export class SkillStore {
|
||||
readonly workspacePath: string;
|
||||
readonly skillsPath: string;
|
||||
private writeQueue: Promise<unknown> = Promise.resolve();
|
||||
|
||||
constructor(workspacePath: string) {
|
||||
this.workspacePath = path.resolve(workspacePath);
|
||||
this.skillsPath = path.join(this.workspacePath, ".agents", "skills");
|
||||
}
|
||||
|
||||
/** 判断 Codex 返回的绝对路径是否属于本 Store 的标准 Skill 入口。 */
|
||||
isManagedPath(filePath: string) {
|
||||
if (!path.isAbsolute(filePath)) return false;
|
||||
const relative = path.relative(this.skillsPath, path.resolve(filePath));
|
||||
const segments = relative.split(path.sep);
|
||||
return segments.length === 2 && validName(segments[0]) && segments[1].toLowerCase() === "skill.md";
|
||||
}
|
||||
|
||||
/** 读取一个可编辑 Skill 的正文与界面元数据。 */
|
||||
async get(name: string): Promise<ManagedSkillDetail> {
|
||||
try {
|
||||
await this.writeQueue.catch(() => undefined);
|
||||
const paths = await this.safeExistingPaths(name);
|
||||
return await this.readDetail(name, paths.skillFile, paths.openAiFile);
|
||||
} catch (error) {
|
||||
throw storeError(error, "读取 Skill 失败");
|
||||
}
|
||||
}
|
||||
|
||||
/** 创建新的画布专属 Skill。 */
|
||||
create(input: CreateManagedSkillInput) {
|
||||
return this.mutate(async () => {
|
||||
const name = skillName(input?.name);
|
||||
const description = skillDescription(input?.description);
|
||||
const instructions = skillInstructions(input?.instructions);
|
||||
const skillInterface = skillInterfaceValue(input?.interface, name);
|
||||
await this.ensureRoot();
|
||||
const skillDir = path.join(this.skillsPath, name);
|
||||
const existing = await lstatOptional(skillDir);
|
||||
if (existing) {
|
||||
if (existing.isSymbolicLink()) throw new SkillStoreError("Skill 目录不能是符号链接或目录联接", 400);
|
||||
throw new SkillStoreError("同名 Skill 已存在", 409);
|
||||
}
|
||||
await fs.mkdir(skillDir);
|
||||
try {
|
||||
const skillFile = path.join(skillDir, "SKILL.md");
|
||||
await writeFileAtomic(skillFile, serializeSkill({ name, description }, instructions));
|
||||
const openAiFile = path.join(skillDir, "agents", "openai.yaml");
|
||||
if (skillInterface) await this.writeOpenAi(openAiFile, {}, skillInterface);
|
||||
return await this.readDetail(name, skillFile, openAiFile);
|
||||
} catch (error) {
|
||||
await fs.rm(skillDir, { recursive: true, force: true }).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** 通过 revision 防止覆盖已被其他窗口或外部编辑器修改的内容。 */
|
||||
update(nameValue: string, input: UpdateManagedSkillInput) {
|
||||
return this.mutate(async () => {
|
||||
const name = skillName(nameValue);
|
||||
const description = skillDescription(input?.description);
|
||||
const instructions = skillInstructions(input?.instructions);
|
||||
const expectedRevision = expectedRevisionValue(input?.expectedRevision);
|
||||
const interfaceInput = input?.interface;
|
||||
const skillInterface = interfaceInput === undefined ? undefined : skillInterfaceValue(interfaceInput, name);
|
||||
const paths = await this.safeExistingPaths(name);
|
||||
const currentSkill = await readSkill(paths.skillFile, name);
|
||||
const currentOpenAi = await readOpenAi(paths.openAiFile, name);
|
||||
assertRevision(expectedRevision, revision(currentSkill.raw, currentOpenAi.raw));
|
||||
const frontmatter = { ...currentSkill.frontmatter, name, description };
|
||||
await writeFileAtomic(paths.skillFile, serializeSkill(frontmatter, instructions));
|
||||
if (interfaceInput !== undefined) {
|
||||
try {
|
||||
await this.writeOpenAi(paths.openAiFile, currentOpenAi.data, skillInterface);
|
||||
} catch (error) {
|
||||
if (!await restoreSkillFiles(paths.skillFile, currentSkill.raw, paths.openAiFile, currentOpenAi.raw)) {
|
||||
throw new SkillStoreError("Skill 更新失败且无法完全恢复,请检查本地文件", 500);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
return await this.readDetail(name, paths.skillFile, paths.openAiFile);
|
||||
});
|
||||
}
|
||||
|
||||
/** 删除 revision 仍匹配的画布专属 Skill。 */
|
||||
delete(nameValue: string, expectedRevisionValueInput: string) {
|
||||
return this.mutate(async () => {
|
||||
const name = skillName(nameValue);
|
||||
const expectedRevision = expectedRevisionValue(expectedRevisionValueInput);
|
||||
const paths = await this.safeExistingPaths(name);
|
||||
const currentSkill = await readSkill(paths.skillFile, name);
|
||||
const currentOpenAi = await readOpenAi(paths.openAiFile, name);
|
||||
assertRevision(expectedRevision, revision(currentSkill.raw, currentOpenAi.raw));
|
||||
await assertTreeHasNoLinks(paths.skillDir);
|
||||
const realRoot = await fs.realpath(this.skillsPath);
|
||||
const realSkill = await fs.realpath(paths.skillDir);
|
||||
if (!inside(realRoot, realSkill)) throw new SkillStoreError("Skill 路径不安全", 400);
|
||||
await fs.rm(realSkill, { recursive: true });
|
||||
});
|
||||
}
|
||||
|
||||
private mutate<T>(operation: () => Promise<T>): Promise<T> {
|
||||
const result = this.writeQueue.catch(() => undefined).then(operation).catch((error) => { throw storeError(error, "修改 Skill 失败"); });
|
||||
this.writeQueue = result.catch(() => undefined);
|
||||
return result;
|
||||
}
|
||||
|
||||
private async ensureRoot() {
|
||||
const workspace = await lstatOptional(this.workspacePath);
|
||||
if (!workspace) throw new SkillStoreError("站点工作空间不存在", 409);
|
||||
if (workspace.isSymbolicLink()) throw new SkillStoreError("站点工作空间不能是符号链接或目录联接", 400);
|
||||
if (!workspace.isDirectory()) throw new SkillStoreError("站点工作空间不是目录", 409);
|
||||
const agentsPath = path.join(this.workspacePath, ".agents");
|
||||
await ensurePlainDirectory(agentsPath);
|
||||
await ensurePlainDirectory(this.skillsPath);
|
||||
const realWorkspace = await fs.realpath(this.workspacePath);
|
||||
const realRoot = await fs.realpath(this.skillsPath);
|
||||
if (!inside(realWorkspace, realRoot)) throw new SkillStoreError("Skill 根目录不安全", 400);
|
||||
}
|
||||
|
||||
private async safeExistingPaths(nameValue: string) {
|
||||
const name = skillName(nameValue);
|
||||
await this.assertExistingRoot();
|
||||
const skillDir = path.join(this.skillsPath, name);
|
||||
const directory = await lstatOptional(skillDir);
|
||||
if (!directory) throw new SkillStoreError("找不到指定 Skill", 404);
|
||||
if (directory.isSymbolicLink() || !directory.isDirectory()) throw new SkillStoreError("Skill 目录不安全", 400);
|
||||
const realRoot = await fs.realpath(this.skillsPath);
|
||||
const realSkill = await fs.realpath(skillDir);
|
||||
if (!inside(realRoot, realSkill)) throw new SkillStoreError("Skill 路径不安全", 400);
|
||||
const skillFile = path.join(skillDir, "SKILL.md");
|
||||
const skillEntry = await lstatOptional(skillFile);
|
||||
if (!skillEntry) throw new SkillStoreError("Skill 缺少 SKILL.md", 404);
|
||||
if (skillEntry.isSymbolicLink() || !skillEntry.isFile()) throw new SkillStoreError("SKILL.md 路径不安全", 400);
|
||||
const agentsDir = path.join(skillDir, "agents");
|
||||
const agentsEntry = await lstatOptional(agentsDir);
|
||||
if (agentsEntry && (agentsEntry.isSymbolicLink() || !agentsEntry.isDirectory())) throw new SkillStoreError("Skill agents 目录不安全", 400);
|
||||
const openAiFile = path.join(agentsDir, "openai.yaml");
|
||||
const openAiEntry = await lstatOptional(openAiFile);
|
||||
if (openAiEntry && (openAiEntry.isSymbolicLink() || !openAiEntry.isFile())) throw new SkillStoreError("openai.yaml 路径不安全", 400);
|
||||
return { skillDir, skillFile, openAiFile };
|
||||
}
|
||||
|
||||
private async assertExistingRoot() {
|
||||
const workspace = await lstatOptional(this.workspacePath);
|
||||
if (!workspace) throw new SkillStoreError("找不到指定 Skill", 404);
|
||||
if (workspace.isSymbolicLink()) throw new SkillStoreError("站点工作空间不能是符号链接或目录联接", 400);
|
||||
if (!workspace.isDirectory()) throw new SkillStoreError("找不到指定 Skill", 404);
|
||||
const agentsPath = path.join(this.workspacePath, ".agents");
|
||||
const agents = await lstatOptional(agentsPath);
|
||||
const root = await lstatOptional(this.skillsPath);
|
||||
if (!agents || !root) throw new SkillStoreError("找不到指定 Skill", 404);
|
||||
if (agents.isSymbolicLink() || !agents.isDirectory() || root.isSymbolicLink() || !root.isDirectory()) throw new SkillStoreError("Skill 路径中存在符号链接或目录联接", 400);
|
||||
const realWorkspace = await fs.realpath(this.workspacePath);
|
||||
const realRoot = await fs.realpath(this.skillsPath);
|
||||
if (!inside(realWorkspace, realRoot)) throw new SkillStoreError("Skill 根目录不安全", 400);
|
||||
}
|
||||
|
||||
private async readDetail(name: string, skillFile: string, openAiFile: string): Promise<ManagedSkillDetail> {
|
||||
const skill = await readSkill(skillFile, name);
|
||||
const openAi = await readOpenAi(openAiFile, name);
|
||||
return {
|
||||
name,
|
||||
description: skill.description,
|
||||
instructions: skill.instructions,
|
||||
...(openAi.interface ? { interface: openAi.interface } : {}),
|
||||
path: skillFile,
|
||||
revision: revision(skill.raw, openAi.raw),
|
||||
managed: true,
|
||||
};
|
||||
}
|
||||
|
||||
private async writeOpenAi(filePath: string, current: Record<string, unknown>, skillInterface?: ManagedSkillInterface) {
|
||||
const agentsDir = path.dirname(filePath);
|
||||
const existing = recordValue(current.interface);
|
||||
delete existing.display_name;
|
||||
delete existing.short_description;
|
||||
delete existing.default_prompt;
|
||||
const interfaceYaml = {
|
||||
...existing,
|
||||
...(skillInterface?.displayName ? { display_name: skillInterface.displayName } : {}),
|
||||
...(skillInterface?.shortDescription ? { short_description: skillInterface.shortDescription } : {}),
|
||||
...(skillInterface?.defaultPrompt ? { default_prompt: skillInterface.defaultPrompt } : {}),
|
||||
};
|
||||
const next = { ...current };
|
||||
if (Object.keys(interfaceYaml).length) next.interface = interfaceYaml;
|
||||
else delete next.interface;
|
||||
if (Object.keys(next).length) {
|
||||
await ensurePlainDirectory(agentsDir);
|
||||
await writeFileAtomic(filePath, stringifyYaml(next, { defaultKeyType: "PLAIN", defaultStringType: "QUOTE_DOUBLE" }));
|
||||
} else {
|
||||
await fs.unlink(filePath).catch((error) => {
|
||||
if (nodeErrorCode(error) !== "ENOENT") throw error;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validName(value: string | undefined): value is string {
|
||||
return Boolean(value && value.length <= MAX_NAME_LENGTH && NAME_PATTERN.test(value));
|
||||
}
|
||||
|
||||
function skillName(value: unknown) {
|
||||
const name = typeof value === "string" ? value : "";
|
||||
if (!validName(name)) throw new SkillStoreError("Skill 名称只能包含小写字母、数字和连字符", 400);
|
||||
return name;
|
||||
}
|
||||
|
||||
function skillDescription(value: unknown) {
|
||||
const description = typeof value === "string" ? value.trim() : "";
|
||||
if (!description) throw new SkillStoreError("请输入 Skill 描述", 400);
|
||||
if (description.length > MAX_DESCRIPTION_LENGTH) throw new SkillStoreError("Skill 描述过长", 400);
|
||||
if (description.includes("<") || description.includes(">")) throw new SkillStoreError("Skill 描述不能包含尖括号", 400);
|
||||
return description;
|
||||
}
|
||||
|
||||
function skillInstructions(value: unknown) {
|
||||
const instructions = typeof value === "string" ? value.trim() : "";
|
||||
if (!instructions) throw new SkillStoreError("请输入 Skill 正文", 400);
|
||||
if (Buffer.byteLength(instructions, "utf8") > MAX_INSTRUCTIONS_BYTES) throw new SkillStoreError("Skill 正文不能超过 256KiB", 400);
|
||||
return instructions;
|
||||
}
|
||||
|
||||
function skillInterfaceValue(value: unknown, name: string): ManagedSkillInterface | undefined {
|
||||
if (value === undefined || value === null) return undefined;
|
||||
if (typeof value !== "object" || Array.isArray(value)) throw new SkillStoreError("Skill 界面元数据无效", 400);
|
||||
const interfaceValue = value as Record<string, unknown>;
|
||||
const displayName = optionalText(interfaceValue.displayName, "显示名称", MAX_DISPLAY_NAME_LENGTH);
|
||||
const shortDescription = optionalText(interfaceValue.shortDescription, "简短描述", MAX_SHORT_DESCRIPTION_LENGTH);
|
||||
const defaultPrompt = optionalText(interfaceValue.defaultPrompt, "默认提示词", MAX_DEFAULT_PROMPT_LENGTH);
|
||||
if (shortDescription && shortDescription.length < MIN_SHORT_DESCRIPTION_LENGTH) throw new SkillStoreError(`简短描述不能少于 ${MIN_SHORT_DESCRIPTION_LENGTH} 个字符`, 400);
|
||||
if (defaultPrompt && !new RegExp(`\\$${name}(?![A-Za-z0-9_-]|:[A-Za-z0-9_-])`).test(defaultPrompt)) throw new SkillStoreError(`默认提示词必须包含 $${name}`, 400);
|
||||
return displayName || shortDescription || defaultPrompt ? { ...(displayName ? { displayName } : {}), ...(shortDescription ? { shortDescription } : {}), ...(defaultPrompt ? { defaultPrompt } : {}) } : undefined;
|
||||
}
|
||||
|
||||
function optionalText(value: unknown, label: string, maxLength: number) {
|
||||
if (value === undefined || value === null || value === "") return undefined;
|
||||
if (typeof value !== "string") throw new SkillStoreError(`${label}格式无效`, 400);
|
||||
const text = value.trim();
|
||||
if (text.length > maxLength) throw new SkillStoreError(`${label}过长`, 400);
|
||||
return text || undefined;
|
||||
}
|
||||
|
||||
function expectedRevisionValue(value: unknown) {
|
||||
const expected = typeof value === "string" ? value : "";
|
||||
if (!/^[a-f0-9]{64}$/.test(expected)) throw new SkillStoreError("Skill revision 无效,请重新加载后再试", 400);
|
||||
return expected;
|
||||
}
|
||||
|
||||
function assertRevision(expected: string, current: string) {
|
||||
if (expected !== current) throw new SkillStoreError("Skill 已被其他窗口或外部编辑器修改,请重新加载后再试", 409);
|
||||
}
|
||||
|
||||
function serializeSkill(frontmatter: Record<string, unknown>, instructions: string) {
|
||||
return matter.stringify(`${instructions.trim()}\n`, frontmatter);
|
||||
}
|
||||
|
||||
async function readSkill(filePath: string, expectedName: string): Promise<SkillDocument> {
|
||||
const raw = await fs.readFile(filePath, "utf8");
|
||||
let parsed: matter.GrayMatterFile<string>;
|
||||
try {
|
||||
parsed = matter(raw);
|
||||
} catch {
|
||||
throw new SkillStoreError("SKILL.md frontmatter 格式无效", 409);
|
||||
}
|
||||
const frontmatter = recordValue(parsed.data);
|
||||
if (frontmatter.name !== expectedName) throw new SkillStoreError("SKILL.md 中的名称与目录不一致", 409);
|
||||
return {
|
||||
raw,
|
||||
frontmatter,
|
||||
description: skillDescription(frontmatter.description),
|
||||
instructions: skillInstructions(parsed.content),
|
||||
};
|
||||
}
|
||||
|
||||
async function readOpenAi(filePath: string, expectedName: string): Promise<OpenAiDocument> {
|
||||
const entry = await lstatOptional(filePath);
|
||||
if (!entry) return { raw: "", data: {} };
|
||||
const raw = await fs.readFile(filePath, "utf8");
|
||||
let data: Record<string, unknown>;
|
||||
try {
|
||||
const parsed = parseYaml(raw);
|
||||
if (parsed !== null && parsed !== undefined && (typeof parsed !== "object" || Array.isArray(parsed))) throw new Error("invalid document");
|
||||
data = recordValue(parsed);
|
||||
} catch {
|
||||
throw new SkillStoreError("agents/openai.yaml 格式无效", 409);
|
||||
}
|
||||
if (data.interface !== undefined && data.interface !== null && (typeof data.interface !== "object" || Array.isArray(data.interface))) {
|
||||
throw new SkillStoreError("agents/openai.yaml interface 格式无效", 409);
|
||||
}
|
||||
const value = data.interface as Record<string, unknown> | null | undefined;
|
||||
const skillInterface = skillInterfaceValue({ displayName: value?.display_name, shortDescription: value?.short_description, defaultPrompt: value?.default_prompt }, expectedName);
|
||||
return { raw, data, ...(skillInterface ? { interface: skillInterface } : {}) };
|
||||
}
|
||||
|
||||
async function restoreSkillFiles(skillFile: string, skillRaw: string, openAiFile: string, openAiRaw: string) {
|
||||
try {
|
||||
await writeFileAtomic(skillFile, skillRaw);
|
||||
if (openAiRaw) {
|
||||
await ensurePlainDirectory(path.dirname(openAiFile));
|
||||
await writeFileAtomic(openAiFile, openAiRaw);
|
||||
} else {
|
||||
await fs.unlink(openAiFile).catch((error) => {
|
||||
if (nodeErrorCode(error) !== "ENOENT") throw error;
|
||||
});
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeFileAtomic(filePath: string, content: string) {
|
||||
const temporary = path.join(path.dirname(filePath), `.${path.basename(filePath)}.${process.pid}.${crypto.randomUUID()}.tmp`);
|
||||
try {
|
||||
await fs.writeFile(temporary, content, { encoding: "utf8", flag: "wx" });
|
||||
await fs.rename(temporary, filePath);
|
||||
} finally {
|
||||
await fs.unlink(temporary).catch((error) => {
|
||||
if (nodeErrorCode(error) !== "ENOENT") throw error;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function revision(skillRaw: string, openAiRaw: string) {
|
||||
return crypto.createHash("sha256").update(skillRaw).update("\0").update(openAiRaw).digest("hex");
|
||||
}
|
||||
|
||||
async function ensurePlainDirectory(directory: string) {
|
||||
const entry = await lstatOptional(directory);
|
||||
if (!entry) {
|
||||
await fs.mkdir(directory);
|
||||
return;
|
||||
}
|
||||
if (entry.isSymbolicLink() || !entry.isDirectory()) throw new SkillStoreError("Skill 路径中存在符号链接或目录联接", 400);
|
||||
}
|
||||
|
||||
async function assertTreeHasNoLinks(directory: string): Promise<void> {
|
||||
for (const entry of await fs.readdir(directory, { withFileTypes: true })) {
|
||||
const entryPath = path.join(directory, entry.name);
|
||||
const metadata = await fs.lstat(entryPath);
|
||||
if (metadata.isSymbolicLink()) throw new SkillStoreError("Skill 目录中存在符号链接或目录联接,无法删除", 400);
|
||||
if (metadata.isDirectory()) await assertTreeHasNoLinks(entryPath);
|
||||
}
|
||||
}
|
||||
|
||||
function inside(parent: string, child: string) {
|
||||
const relative = path.relative(parent, child);
|
||||
return Boolean(relative && !relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative));
|
||||
}
|
||||
|
||||
async function lstatOptional(filePath: string) {
|
||||
try {
|
||||
return await fs.lstat(filePath);
|
||||
} catch (error) {
|
||||
if (nodeErrorCode(error) === "ENOENT") return undefined;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function recordValue(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? { ...(value as Record<string, unknown>) } : {};
|
||||
}
|
||||
|
||||
function nodeErrorCode(error: unknown) {
|
||||
return error && typeof error === "object" && "code" in error ? String((error as { code?: unknown }).code || "") : "";
|
||||
}
|
||||
|
||||
function storeError(error: unknown, fallback: string) {
|
||||
if (error instanceof SkillStoreError) return error;
|
||||
if (nodeErrorCode(error) === "ENOENT") return new SkillStoreError("找不到指定 Skill", 404);
|
||||
if (["EEXIST", "ENOTEMPTY", "EPERM", "EACCES"].includes(nodeErrorCode(error))) return new SkillStoreError("Skill 文件当前无法修改", 409);
|
||||
return new SkillStoreError(fallback, 500);
|
||||
}
|
||||
Reference in New Issue
Block a user