agent-loft 0.2.31 → 0.2.32

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.
@@ -19,6 +19,7 @@ import { describeSchedule, formatDateTime, nextRun, normalizeSchedule, parseLoca
19
19
  import { TASK_TOOL_TIMEOUT_SEC, tasksSocketPath, verifyTaskCaller } from "./tasks-mcp.js";
20
20
  import { createTask, deleteTask, getTask, listTasks, TASKS_PER_USER, updateTask, type Task, type TaskInput } from "./tasks.js";
21
21
  import { getUser } from "./users.js";
22
+ import { SKILL_TOOL_NAMES, skillToolCall } from "./skills-bridge.js";
22
23
 
23
24
  type Reply = { text: string; isError?: boolean };
24
25
  const fail = (text: string): Reply => ({ text, isError: true });
@@ -85,6 +86,8 @@ async function call(userId: string, convKey: string, tool: string, args: Record<
85
86
  if (!user || user.disabled) return fail("账号已停用。");
86
87
  const conv = getConversation(convKey);
87
88
  if (!conv || memoryOwnerOf(conv) !== user.id) return fail("这条对话不属于当前用户,不能管理定时任务。");
89
+ // 技能工具走的也是这条通道(见 tasks-mcp.ts 的 skillsMcpSpawn)
90
+ if (SKILL_TOOL_NAMES.includes(tool)) return skillToolCall(user.id, convKey, tool, args);
88
91
  const now = Date.now();
89
92
  const inTaskTurn = !!hub.currentTaskTurn(convKey);
90
93
 
@@ -211,7 +214,8 @@ export function startTasksBridge(): void {
211
214
  buf += chunk;
212
215
  const n = buf.indexOf("\n");
213
216
  if (n < 0) {
214
- if (buf.length > 64 * 1024) sock.destroy();
217
+ // 技能工具会带着整份 SKILL.md 和附带文件过来,放宽到 8MB
218
+ if (buf.length > 8 * 1024 * 1024) sock.destroy();
215
219
  return;
216
220
  }
217
221
  const line = buf.slice(0, n);
@@ -36,6 +36,17 @@ export function verifyTaskCaller(userId: string, convKey: string, sig: string):
36
36
  return got.length === want.length && crypto.timingSafeEqual(got, want);
37
37
  }
38
38
 
39
+ /**
40
+ * 技能工具(让 agent 帮用户建、改技能)也走这条通道:同一个服务脚本,AGENT_LOFT_TOOLSET=skills 时
41
+ * 只报技能那几个工具,主进程按工具名分给 skills-bridge.ts。单独挂成一个 MCP 服务,工具名才叫得顺。
42
+ */
43
+ export const SKILLS_MCP_NAME = "agent_loft_skills";
44
+
45
+ export function skillsMcpSpawn(userId: string, convKey: string, via: string): StdioSpawn {
46
+ const base = tasksMcpSpawn(userId, convKey, via);
47
+ return { ...base, env: { ...base.env, AGENT_LOFT_TOOLSET: "skills" } };
48
+ }
49
+
39
50
  export function tasksMcpSpawn(userId: string, convKey: string, via: string): StdioSpawn {
40
51
  return {
41
52
  command: process.execPath,
package/src/core/types.ts CHANGED
@@ -188,8 +188,27 @@ export type SessionOptions = {
188
188
  };
189
189
  /** 模式换过、这个 agent 会话还不知道:换不了系统提示的 agent(Codex)要在第一条消息里说一声 */
190
190
  announceMode?: boolean;
191
- /** 给 agent 定时任务工具(core/tasks-mcp.ts):替谁、在哪条对话里管理。只有有主人的对话才给 */
191
+ /**
192
+ * 给 agent 定时任务工具(core/tasks-mcp.ts):替谁、在哪条对话里管理。只有有主人的对话才给。
193
+ * 技能工具(让 agent 帮用户建、改技能)也跟着它一起给。
194
+ */
192
195
  tasks?: { userId: string; convKey: string };
196
+ /** 这条对话主人的技能(core/skill-runtime.ts)。没主人的对话不给:机器上的技能照旧,没有 agent-loft 的技能 */
197
+ skills?: SessionSkills;
198
+ };
199
+
200
+ export type SessionSkills = {
201
+ userId: string;
202
+ /** 管理员:机器上原有的技能照常能用;成员:只有 agent-loft 的技能和 agent 自带的 */
203
+ admin: boolean;
204
+ /** Claude 插件目录(里面是这个人启用的技能),一个都没有就不给 */
205
+ pluginDir?: string;
206
+ /** 启用的技能:名字、说明、SKILL.md 的路径 */
207
+ list: { name: string; description: string; path: string }[];
208
+ /** 要注入的环境变量(技能声明过、这个人填了值的) */
209
+ env: Record<string, string>;
210
+ /** 变量配没配好的说明,拼进系统提示 / 背景 */
211
+ prompt?: string;
193
212
  };
194
213
 
195
214
  export type ConversationMode = "chat" | "work";
@@ -0,0 +1,98 @@
1
+ // 最小的 zip 解包:上传技能包(.zip / .skill)用。只认最常见的两种:不压缩(stored)和 deflate,
2
+ // 加密的、zip64 的直接报错。不落盘,解到内存里交给调用方校验路径、大小后再写。
3
+ import zlib from "node:zlib";
4
+
5
+ export type ZipEntry = { path: string; data: Buffer };
6
+
7
+ const EOCD_SIG = 0x06054b50;
8
+ const CENTRAL_SIG = 0x02014b50;
9
+ const LOCAL_SIG = 0x04034b50;
10
+
11
+ /** maxTotal:解压后的总大小上限(防 zip 炸弹),超了就报错 */
12
+ export function readZip(buf: Buffer, maxTotal: number, maxEntries = 1000): ZipEntry[] {
13
+ // 目录尾记录在最后 22 字节 + 最长 64KB 的注释里
14
+ let eocd = -1;
15
+ for (let i = buf.length - 22; i >= Math.max(0, buf.length - 22 - 0xffff); i--) {
16
+ if (buf.readUInt32LE(i) === EOCD_SIG) {
17
+ eocd = i;
18
+ break;
19
+ }
20
+ }
21
+ if (eocd < 0) throw new Error("不是 zip 文件");
22
+ const count = buf.readUInt16LE(eocd + 10);
23
+ let offset = buf.readUInt32LE(eocd + 16);
24
+ if (count === 0xffff || offset === 0xffffffff) throw new Error("不支持 zip64 格式的压缩包");
25
+ if (count > maxEntries) throw new Error(`压缩包里的文件太多(最多 ${maxEntries} 个)`);
26
+
27
+ const out: ZipEntry[] = [];
28
+ let total = 0;
29
+ for (let n = 0; n < count; n++) {
30
+ if (offset + 46 > buf.length || buf.readUInt32LE(offset) !== CENTRAL_SIG) throw new Error("压缩包损坏");
31
+ const flags = buf.readUInt16LE(offset + 8);
32
+ const method = buf.readUInt16LE(offset + 10);
33
+ const compressed = buf.readUInt32LE(offset + 20);
34
+ const size = buf.readUInt32LE(offset + 24);
35
+ const nameLen = buf.readUInt16LE(offset + 28);
36
+ const extraLen = buf.readUInt16LE(offset + 30);
37
+ const commentLen = buf.readUInt16LE(offset + 32);
38
+ const local = buf.readUInt32LE(offset + 42);
39
+ // 第 11 位:文件名是 UTF-8。没标的多半也是(macOS / 新版 Windows),按 UTF-8 读
40
+ const name = buf.subarray(offset + 46, offset + 46 + nameLen).toString("utf8");
41
+ offset += 46 + nameLen + extraLen + commentLen;
42
+ if (name.endsWith("/")) continue; // 目录
43
+ if (flags & 1) throw new Error("不支持加密的压缩包");
44
+ if (size === 0xffffffff || compressed === 0xffffffff) throw new Error("不支持 zip64 格式的压缩包");
45
+ total += size;
46
+ if (total > maxTotal) throw new Error(`解压后太大(上限 ${Math.round(maxTotal / 1024 / 1024)}MB)`);
47
+ if (local + 30 > buf.length || buf.readUInt32LE(local) !== LOCAL_SIG) throw new Error("压缩包损坏");
48
+ const start = local + 30 + buf.readUInt16LE(local + 26) + buf.readUInt16LE(local + 28);
49
+ const raw = buf.subarray(start, start + compressed);
50
+ let data: Buffer;
51
+ if (method === 0) data = Buffer.from(raw);
52
+ else if (method === 8) data = zlib.inflateRawSync(raw, { maxOutputLength: Math.max(1, size) });
53
+ else throw new Error(`不支持的压缩方式(${method})`);
54
+ if (data.length !== size) throw new Error("压缩包损坏");
55
+ out.push({ path: name, data });
56
+ }
57
+ return out;
58
+ }
59
+
60
+ /** 测试和导出用:打一个不压缩的 zip */
61
+ export function writeZip(entries: ZipEntry[]): Buffer {
62
+ const locals: Buffer[] = [];
63
+ const centrals: Buffer[] = [];
64
+ let offset = 0;
65
+ for (const e of entries) {
66
+ const name = Buffer.from(e.path, "utf8");
67
+ const crc = zlib.crc32(e.data);
68
+ const local = Buffer.alloc(30);
69
+ local.writeUInt32LE(LOCAL_SIG, 0);
70
+ local.writeUInt16LE(20, 4);
71
+ local.writeUInt16LE(0x800, 6);
72
+ local.writeUInt32LE(crc, 14);
73
+ local.writeUInt32LE(e.data.length, 18);
74
+ local.writeUInt32LE(e.data.length, 22);
75
+ local.writeUInt16LE(name.length, 26);
76
+ locals.push(local, name, e.data);
77
+ const central = Buffer.alloc(46);
78
+ central.writeUInt32LE(CENTRAL_SIG, 0);
79
+ central.writeUInt16LE(20, 4);
80
+ central.writeUInt16LE(20, 6);
81
+ central.writeUInt16LE(0x800, 8);
82
+ central.writeUInt32LE(crc, 16);
83
+ central.writeUInt32LE(e.data.length, 20);
84
+ central.writeUInt32LE(e.data.length, 24);
85
+ central.writeUInt16LE(name.length, 28);
86
+ central.writeUInt32LE(offset, 42);
87
+ centrals.push(central, name);
88
+ offset += 30 + name.length + e.data.length;
89
+ }
90
+ const dir = Buffer.concat(centrals);
91
+ const end = Buffer.alloc(22);
92
+ end.writeUInt32LE(EOCD_SIG, 0);
93
+ end.writeUInt16LE(entries.length, 8);
94
+ end.writeUInt16LE(entries.length, 10);
95
+ end.writeUInt32LE(dir.length, 12);
96
+ end.writeUInt32LE(offset, 16);
97
+ return Buffer.concat([...locals, dir, end]);
98
+ }
package/src/main.ts CHANGED
@@ -59,6 +59,10 @@ async function main(): Promise<void> {
59
59
  startTaskRunner();
60
60
  // 先查一遍 agent 装没装、登没登录,网页打开时新对话里就只列出能用的
61
61
  void agentHealth().catch(() => {});
62
+ // 读一次 Claude 的命令表:成员会话的技能白名单要带上 Claude Code 自带的那些(见 claude/session.ts 的 claudeBuiltins)
63
+ if (config().agents.claude.enabled) {
64
+ setTimeout(() => void hub.driver("claude")?.commands?.(config().workspaceRoot).catch(() => {}), 5000).unref();
65
+ }
62
66
 
63
67
  console.log(`[启动] workspace=${config().workspaceRoot}`);
64
68
  if (config().workspaceRoot === process.cwd()) {
@@ -1,4 +1,5 @@
1
1
  // 定时任务工具的 MCP 服务(stdio):给 agent 用的 list / create / update / delete。
2
+ // 环境变量 AGENT_LOFT_TOOLSET=skills 时换成技能工具(list / get / create / update_skill),走同一条通道。
2
3
  //
3
4
  // 这里只是个传话的:每次调用原样转给主进程(core/tasks-bridge.ts,经 unix socket),带上拉起时
4
5
  // 定死的用户、对话和签名(见 core/tasks-mcp.ts)。校验、授权卡、真正的增删改都在主进程里做。
@@ -103,6 +104,126 @@ const TOOLS = [
103
104
  },
104
105
  ];
105
106
 
107
+ // ---------- 技能工具(AGENT_LOFT_TOOLSET=skills 时只报这几个) ----------
108
+
109
+ const VARS = {
110
+ type: "array",
111
+ description:
112
+ "技能要用的环境变量(API 地址、密钥之类)。值由用户自己在网页上填,运行时注入到他的会话环境里,SKILL.md 里用 $NAME 引用。" +
113
+ "变量名用大写字母、数字和 _,最好带上技能相关的前缀(比如 WEATHER_API_KEY)。",
114
+ items: {
115
+ type: "object",
116
+ properties: {
117
+ name: { type: "string" },
118
+ description: { type: "string", description: "给用户看的说明:这是什么、去哪里拿" },
119
+ secret: { type: "boolean", description: "密钥、令牌之类:网页上不回显" },
120
+ required: { type: "boolean" },
121
+ hosts: {
122
+ type: "array",
123
+ items: { type: "string" },
124
+ description:
125
+ "secret 时建议给:这个密钥只允许发往的地址(域名或 IP,可带端口,比如 api.example.com)。给了就是受保护的密钥:不放进环境变量,agent 看不到值," +
126
+ "只能用 skill_request 工具在请求里写 {{NAME}} 由 agent-loft 代发。这时 body 里要写成用 skill_request 调用,而不是 curl $NAME。",
127
+ },
128
+ },
129
+ required: ["name"],
130
+ additionalProperties: false,
131
+ },
132
+ };
133
+
134
+ const FILES = {
135
+ type: "array",
136
+ description: "技能附带的文件(脚本、模板、参考资料),路径相对技能目录,比如 scripts/fetch.sh。只支持文本。",
137
+ items: {
138
+ type: "object",
139
+ properties: { path: { type: "string" }, content: { type: "string" } },
140
+ required: ["path", "content"],
141
+ additionalProperties: false,
142
+ },
143
+ };
144
+
145
+ const SKILL_TOOLS = [
146
+ {
147
+ name: "list_skills",
148
+ description: "列出用户在 agent-loft 里启用着的技能(名字、说明、谁建的、变量配没配)。",
149
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
150
+ annotations: { title: "查看技能", readOnlyHint: true, openWorldHint: false },
151
+ },
152
+ {
153
+ name: "get_skill",
154
+ description: "读一个技能的 SKILL.md 和附带文件清单;给 file 时读那个附带文件。改技能之前先读。",
155
+ inputSchema: {
156
+ type: "object",
157
+ properties: { name: { type: "string", description: "技能名" }, file: { type: "string", description: "附带文件的相对路径(可选)" } },
158
+ required: ["name"],
159
+ additionalProperties: false,
160
+ },
161
+ annotations: { title: "读技能", readOnlyHint: true, openWorldHint: false },
162
+ },
163
+ {
164
+ name: "create_skill",
165
+ description:
166
+ "把一套做事的方法存成用户的技能(agent-loft 管理,Claude Code 和 Codex 都能用;格式同 Claude Code 的 SKILL.md)。" +
167
+ "用户说「把这个做成技能 / 以后都这么做 / 存成 skill」时用。写法:description 一句话说清什么时候该用它(agent 靠它判断);" +
168
+ "body 是给 agent 的操作说明(Markdown),步骤具体、可以直接照做;要调接口的,把地址、密钥做成 vars,body 里用 $NAME 引用,不要把真实的密钥写进去。" +
169
+ "调用后会弹授权卡给用户确认。建好后只有用户自己看得到,下一轮起可用。不要写到 ~/.claude/skills 之类的目录里,那是机器上的,只有管理员能用。",
170
+ inputSchema: {
171
+ type: "object",
172
+ properties: {
173
+ name: { type: "string", description: "技能名:小写字母、数字和 -,最长 48 个字符,全站唯一" },
174
+ description: { type: "string", description: "什么时候用这个技能(一两句话)" },
175
+ body: { type: "string", description: "SKILL.md 的正文(不含 frontmatter)" },
176
+ files: FILES,
177
+ vars: VARS,
178
+ },
179
+ required: ["name", "description", "body"],
180
+ additionalProperties: false,
181
+ },
182
+ annotations: { title: "新建技能", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
183
+ },
184
+ {
185
+ name: "skill_request",
186
+ description:
187
+ "替你发一个 HTTP 请求,url、headers、body 里的 {{变量名}} 由 agent-loft 换成用户在技能里填的值 —— 你看不到密钥本身。" +
188
+ "技能里的受保护密钥(list_skills 里标了「受保护」的)只能这样用,而且只能发往用户确认过的地址;普通变量也可以这样用。" +
189
+ "不跟随跳转;响应里出现的变量值会被抹掉;最多返回 256KB 文本。",
190
+ inputSchema: {
191
+ type: "object",
192
+ properties: {
193
+ method: { type: "string", enum: ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD"], description: "默认 GET" },
194
+ url: { type: "string", description: "完整地址,比如 https://api.example.com/v1/weather?city=北京&key={{WEATHER_API_KEY}}" },
195
+ headers: { type: "object", additionalProperties: { type: "string" }, description: "比如 { \"Authorization\": \"Bearer {{MY_TOKEN}}\" }" },
196
+ body: { type: "string", description: "请求体(JSON 记得自己序列化,并带上 content-type)" },
197
+ },
198
+ required: ["url"],
199
+ additionalProperties: false,
200
+ },
201
+ // openWorldHint 不标:Codex 见到它会先弹 MCP 审批(agent-loft 不接,等于拒绝)。能发往哪里由 agent-loft 自己卡着
202
+ annotations: { title: "代发请求", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
203
+ },
204
+ {
205
+ name: "update_skill",
206
+ description: "修改用户自己建的一个技能:说明、正文、附带文件、变量,只给要改的。会弹授权卡让用户确认。分享出去的技能改了之后,装了的人也会用上新的。",
207
+ inputSchema: {
208
+ type: "object",
209
+ properties: {
210
+ name: { type: "string", description: "技能名(不能改名)" },
211
+ description: { type: "string" },
212
+ body: { type: "string", description: "新的 SKILL.md 正文(整段替换)" },
213
+ files: FILES,
214
+ delete_files: { type: "array", items: { type: "string" }, description: "要删掉的附带文件" },
215
+ vars: VARS,
216
+ },
217
+ required: ["name"],
218
+ additionalProperties: false,
219
+ },
220
+ annotations: { title: "修改技能", readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
221
+ },
222
+ ];
223
+
224
+ const skillsOnly = process.env.AGENT_LOFT_TOOLSET === "skills";
225
+ const ACTIVE = skillsOnly ? SKILL_TOOLS : TOOLS;
226
+
106
227
  function text(t: string, isError = false) {
107
228
  return { content: [{ type: "text", text: t }], ...(isError ? { isError: true } : {}) };
108
229
  }
@@ -111,7 +232,7 @@ function text(t: string, isError = false) {
111
232
  function forward(tool: string, args: Record<string, unknown>): Promise<{ text: string; isError?: boolean }> {
112
233
  return new Promise((resolve) => {
113
234
  if (!socketPath || !caller.user || !caller.conv) {
114
- resolve({ text: "定时任务工具没有正确配置(缺少用户或对话)。", isError: true });
235
+ resolve({ text: `${skillsOnly ? "技能" : "定时任务"}工具没有正确配置(缺少用户或对话)。`, isError: true });
115
236
  return;
116
237
  }
117
238
  const sock = net.createConnection(socketPath);
@@ -147,16 +268,16 @@ async function handle(req: Request): Promise<unknown> {
147
268
  return {
148
269
  protocolVersion: typeof req.params?.protocolVersion === "string" ? req.params.protocolVersion : "2025-06-18",
149
270
  capabilities: { tools: {} },
150
- serverInfo: { name: "agent-loft-tasks", version: "1.0.0" },
271
+ serverInfo: { name: skillsOnly ? "agent-loft-skills" : "agent-loft-tasks", version: "1.0.0" },
151
272
  };
152
273
  case "ping":
153
274
  return {};
154
275
  case "tools/list":
155
- return { tools: TOOLS };
276
+ return { tools: ACTIVE };
156
277
  case "tools/call": {
157
278
  const params = req.params ?? {};
158
279
  const name = String(params.name ?? "");
159
- if (!TOOLS.some((t) => t.name === name)) return text(`没有工具 ${name}`, true);
280
+ if (!ACTIVE.some((t) => t.name === name)) return text(`没有工具 ${name}`, true);
160
281
  const res = await forward(name, (params.arguments as Record<string, unknown> | undefined) ?? {});
161
282
  return text(res.text, !!res.isError);
162
283
  }