agent-loft 0.2.31 → 0.2.33

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.
Files changed (48) hide show
  1. package/README.md +14 -5
  2. package/package.json +1 -1
  3. package/src/agents/claude/driver.ts +90 -8
  4. package/src/agents/claude/session.ts +81 -12
  5. package/src/agents/codex/session.ts +70 -13
  6. package/src/channels/feishu/bot.ts +2 -2
  7. package/src/channels/web/api.ts +72 -2
  8. package/src/channels/web/conv-pages.ts +68 -0
  9. package/src/channels/web/http.ts +2 -2
  10. package/src/channels/web/server.ts +20 -3
  11. package/src/channels/web/skills-api.ts +225 -0
  12. package/src/core/chat-mode.ts +3 -3
  13. package/src/core/command-catalog.ts +8 -1
  14. package/src/core/commands.ts +3 -0
  15. package/src/core/hub.ts +168 -18
  16. package/src/core/personal.ts +3 -1
  17. package/src/core/skill-runtime.ts +359 -0
  18. package/src/core/skills-bridge.ts +288 -0
  19. package/src/core/skills.ts +676 -0
  20. package/src/core/tasks-bridge.ts +5 -1
  21. package/src/core/tasks-mcp.ts +11 -0
  22. package/src/core/types.ts +44 -3
  23. package/src/core/usage.ts +70 -0
  24. package/src/core/zip.ts +98 -0
  25. package/src/main.ts +4 -0
  26. package/src/mcp/tasks-server.ts +125 -4
  27. package/web/css/composer.css +0 -25
  28. package/web/css/messages.css +169 -0
  29. package/web/css/sidebar.css +5 -5
  30. package/web/css/skills.css +385 -0
  31. package/web/css/topbar.css +131 -0
  32. package/web/index.html +1 -0
  33. package/web/lib/dom.js +3 -1
  34. package/web/lib/markdown.js +84 -14
  35. package/web/lib/ws.js +49 -5
  36. package/web/settings/index.js +7 -3
  37. package/web/settings/skills.js +617 -0
  38. package/web/state.js +4 -3
  39. package/web/views/composer.js +16 -3
  40. package/web/views/interaction.js +12 -4
  41. package/web/views/layout.js +22 -15
  42. package/web/views/mention.js +193 -0
  43. package/web/views/messages.js +239 -81
  44. package/web/views/msg-nav.js +120 -0
  45. package/web/views/quota.js +1 -0
  46. package/web/views/sidebar.js +114 -16
  47. package/web/views/tasks.js +11 -2
  48. package/web/views/topbar.js +168 -40
@@ -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
@@ -100,11 +100,22 @@ export type AgentEvents = {
100
100
  onDropped?: (n: number) => void;
101
101
  /** 上下文窗口占用更新了(一般在每轮结束后) */
102
102
  onContext?: (ctx: ContextUsage) => void;
103
+ /**
104
+ * token 用量更新了。cumulative = 报的是这个会话到目前的累计值(Claude,每轮结束报一次);
105
+ * 否则是新增的量(Codex,每次模型请求报一次),由核心累加
106
+ */
107
+ onUsage?: (usage: TokenUsage, cumulative?: boolean) => void;
103
108
  };
104
109
 
105
110
  /** 上下文窗口占用:used / window 都是 token 数,percent 是 0-100(可能略超 100) */
106
111
  export type ContextUsage = { used: number; window: number; percent: number };
107
112
 
113
+ /**
114
+ * token 用量。input 是没命中缓存的输入;cacheRead 是命中缓存读的;cacheWrite 是写进缓存的(Claude 单独计费);
115
+ * output 含推理,reasoning 是其中推理的部分(Codex 才有)。costUsd 是 agent 自己算的费用(Claude 才有)
116
+ */
117
+ export type TokenUsage = { input: number; cacheRead: number; cacheWrite: number; output: number; reasoning?: number; costUsd?: number };
118
+
108
119
  /** 账号额度(订阅的用量窗口)。没有订阅额度(API key 之类)时 driver 返回 null */
109
120
  export type QuotaInfo = {
110
121
  plan?: string;
@@ -148,8 +159,13 @@ export type SessionSummary = {
148
159
  };
149
160
 
150
161
  export type HistoryMessage = {
151
- role: "user" | "assistant";
162
+ /** question:agent 问过的问题和你的回答(答过的提问卡,刷新后照样显示) */
163
+ role: "user" | "assistant" | "question";
152
164
  text: string;
165
+ /** 助手消息:被提问卡打断的前半段(只显示过程,不带复制 / 分叉 / 脚注) */
166
+ segment?: boolean;
167
+ /** 提问:问了什么、选了哪些(按问题下标)、自由作答的内容;skipped = 没作答(跳过 / 超时) */
168
+ question?: { id: string; title: string; questions: QuestionSpec[]; selections: number[][]; freeText: Record<number, string>; skipped?: boolean };
153
169
  /** 助手这一条之前的过程摘要 */
154
170
  steps?: string[];
155
171
  /** 毫秒时间戳;助手消息是这一轮结束的时间 */
@@ -188,16 +204,41 @@ export type SessionOptions = {
188
204
  };
189
205
  /** 模式换过、这个 agent 会话还不知道:换不了系统提示的 agent(Codex)要在第一条消息里说一声 */
190
206
  announceMode?: boolean;
191
- /** 给 agent 定时任务工具(core/tasks-mcp.ts):替谁、在哪条对话里管理。只有有主人的对话才给 */
207
+ /**
208
+ * 给 agent 定时任务工具(core/tasks-mcp.ts):替谁、在哪条对话里管理。只有有主人的对话才给。
209
+ * 技能工具(让 agent 帮用户建、改技能)也跟着它一起给。
210
+ */
192
211
  tasks?: { userId: string; convKey: string };
212
+ /** 这条对话主人的技能(core/skill-runtime.ts)。没主人的对话不给:机器上的技能照旧,没有 agent-loft 的技能 */
213
+ skills?: SessionSkills;
214
+ };
215
+
216
+ export type SessionSkills = {
217
+ userId: string;
218
+ /** 管理员:机器上原有的技能照常能用;成员:只有 agent-loft 的技能和 agent 自带的 */
219
+ admin: boolean;
220
+ /** Claude 插件目录(里面是这个人启用的技能),一个都没有就不给 */
221
+ pluginDir?: string;
222
+ /** 启用的技能:名字、说明、SKILL.md 的路径 */
223
+ list: { name: string; description: string; path: string }[];
224
+ /** 要注入的环境变量(技能声明过、这个人填了值的) */
225
+ env: Record<string, string>;
226
+ /** 变量配没配好的说明,拼进系统提示 / 背景 */
227
+ prompt?: string;
193
228
  };
194
229
 
195
230
  export type ConversationMode = "chat" | "work";
196
231
 
232
+ /**
233
+ * 中断的方式。默认(停止):这一轮和排着队的都不要了。
234
+ * keepQueue(打断并发送):只停这一轮,排着的留着;给了 first 就把它挪到最前面,这一轮一结束就轮到它
235
+ */
236
+ export type InterruptOptions = { keepQueue?: boolean; first?: UserInput };
237
+
197
238
  export interface AgentSession {
198
239
  readonly sessionId: string | undefined;
199
240
  send(input: UserInput): void;
200
- interrupt(): Promise<void>;
241
+ interrupt(opts?: InterruptOptions): Promise<void>;
201
242
  setModel(model?: string, effort?: string): Promise<void>;
202
243
  describeRuntime(): Promise<RuntimeInfo>;
203
244
  /** 手上已知的模型 / 思考程度,同步返回:不拉起进程、不发请求 */
@@ -0,0 +1,70 @@
1
+ // 每个 agent 会话累计消耗的 token(页面右上角「更多」里显示分布)。
2
+ //
3
+ // Claude 每轮结束报一次这个会话的累计值(恢复 / 分叉来的会话也接着原来的总数算);Codex 每次模型请求报一次增量。
4
+ // 都按会话 id 记在这里:页面重新加载、服务重启后还在。
5
+ // 按 agent 会话算,不按对话:/new、恢复旧会话、切 agent 之后显示的都是现在这个会话的。
6
+ // Codex 升级前跑过的轮次没有记录,从升级后开始算。
7
+ import path from "node:path";
8
+ import { dataDir, readJson, writeJsonAtomic } from "../home.js";
9
+ import type { TokenUsage } from "./types.js";
10
+
11
+ export type UsageTotals = Required<Omit<TokenUsage, "costUsd">> & { costUsd?: number; at: number };
12
+ type Store = Record<string, UsageTotals>;
13
+
14
+ const FILE = () => path.join(dataDir, "usage.json");
15
+ const MAX_SESSIONS = 1000;
16
+
17
+ let store: Store | null = null;
18
+ let timer: NodeJS.Timeout | null = null;
19
+
20
+ function load(): Store {
21
+ return (store ??= readJson<Store>(FILE(), {}));
22
+ }
23
+
24
+ function write(): void {
25
+ const s = load();
26
+ // 只留最近用过的会话
27
+ const keys = Object.keys(s).sort((a, b) => s[b]!.at - s[a]!.at);
28
+ for (const k of keys.slice(MAX_SESSIONS)) delete s[k];
29
+ writeJsonAtomic(FILE(), s);
30
+ }
31
+
32
+ function persistSoon(): void {
33
+ if (timer) return;
34
+ timer = setTimeout(() => {
35
+ timer = null;
36
+ write();
37
+ }, 1000);
38
+ timer.unref();
39
+ }
40
+
41
+ /** 退出前把还没写的落盘 */
42
+ export function flushUsage(): void {
43
+ if (!timer) return;
44
+ clearTimeout(timer);
45
+ timer = null;
46
+ write();
47
+ }
48
+
49
+ const num = (v: unknown): number => (typeof v === "number" && Number.isFinite(v) && v > 0 ? v : 0);
50
+
51
+ const FIELDS = ["input", "cacheRead", "cacheWrite", "output", "reasoning"] as const;
52
+
53
+ /**
54
+ * cumulative:报的是累计值,每项取较大的(累计值只增不减;恢复的会话万一没从原来的总数接着算,
55
+ * 宁可先少算,也不把之前的再加一遍)。否则是增量,加上去
56
+ */
57
+ export function recordUsage(sessionId: string | undefined, usage: TokenUsage, cumulative = false): void {
58
+ if (!sessionId) return;
59
+ const s = load();
60
+ const t = (s[sessionId] ??= { input: 0, cacheRead: 0, cacheWrite: 0, output: 0, reasoning: 0, at: 0 });
61
+ for (const f of FIELDS) t[f] = cumulative ? Math.max(t[f], num(usage[f])) : t[f] + num(usage[f]);
62
+ const cost = num(usage.costUsd);
63
+ if (cost) t.costUsd = cumulative ? Math.max(t.costUsd ?? 0, cost) : (t.costUsd ?? 0) + cost;
64
+ t.at = Date.now();
65
+ persistSoon();
66
+ }
67
+
68
+ export function usageOf(sessionId: string | undefined): UsageTotals | undefined {
69
+ return sessionId ? load()[sessionId] : undefined;
70
+ }
@@ -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
  }
@@ -167,31 +167,6 @@ button.ctx-item:hover {
167
167
  flex-shrink: 0;
168
168
  }
169
169
 
170
- .mode-pill {
171
- display: inline-flex;
172
- align-items: center;
173
- height: 32px;
174
- padding: 0 10px;
175
- border-radius: 999px;
176
- font-size: 13px;
177
- color: var(--fg-faint);
178
- white-space: nowrap;
179
- }
180
-
181
- .mode-pill:hover {
182
- background: var(--bg-hover);
183
- color: var(--fg);
184
- }
185
-
186
- .mode-pill.chat {
187
- color: var(--accent);
188
- }
189
-
190
- .topbar .mode-slot {
191
- display: inline-flex;
192
- flex-shrink: 0;
193
- }
194
-
195
170
  .topbar .top-more svg {
196
171
  width: 20px;
197
172
  height: 20px;
@@ -227,6 +227,26 @@ a.att-file:hover {
227
227
  word-break: break-word;
228
228
  }
229
229
 
230
+ /* 太长的用户消息:收起到约 10 行,底部淡出,下面一个「展开显示」 */
231
+ .msg-user.clamped {
232
+ max-height: 260px;
233
+ overflow: hidden;
234
+ -webkit-mask-image: linear-gradient(to bottom, #000 calc(100% - 56px), transparent);
235
+ mask-image: linear-gradient(to bottom, #000 calc(100% - 56px), transparent);
236
+ }
237
+
238
+ .msg-more {
239
+ margin-top: -2px;
240
+ padding: 2px 8px;
241
+ border-radius: 6px;
242
+ font-size: 13px;
243
+ color: var(--fg-faint);
244
+ }
245
+
246
+ .msg-more:hover {
247
+ color: var(--fg);
248
+ }
249
+
230
250
  .msg-user .origin {
231
251
  display: block;
232
252
  font-size: 12px;
@@ -732,3 +752,152 @@ a.att-file:hover {
732
752
  .lightbox-dl:hover {
733
753
  background: rgba(255, 255, 255, 0.26);
734
754
  }
755
+
756
+ /* ---------- 右边的快速定位:每条用户消息一道横线,鼠标移上去展开成列表 ---------- */
757
+ .msg-nav {
758
+ position: absolute;
759
+ right: 14px;
760
+ top: 45%;
761
+ transform: translateY(-50%);
762
+ z-index: 4;
763
+ display: flex;
764
+ align-items: center;
765
+ }
766
+
767
+ .msg-nav[hidden],
768
+ .main.tasks-view .msg-nav {
769
+ display: none;
770
+ }
771
+
772
+ .msg-nav-ticks {
773
+ display: flex;
774
+ flex-direction: column;
775
+ max-height: 50vh;
776
+ overflow-y: auto;
777
+ scrollbar-width: none;
778
+ opacity: 0.55;
779
+ transition: opacity 0.2s;
780
+ }
781
+
782
+ .msg-nav-ticks::-webkit-scrollbar {
783
+ display: none;
784
+ }
785
+
786
+ .msg-nav.scrolling .msg-nav-ticks,
787
+ .msg-nav:hover .msg-nav-ticks {
788
+ opacity: 1;
789
+ }
790
+
791
+ /* 点击范围比横线大:整个 20×10 都能点 */
792
+ .msg-nav-tick {
793
+ flex-shrink: 0;
794
+ width: 20px;
795
+ height: 10px;
796
+ padding: 0;
797
+ display: flex;
798
+ align-items: center;
799
+ justify-content: flex-end;
800
+ }
801
+
802
+ .msg-nav-tick i {
803
+ display: block;
804
+ width: 10px;
805
+ height: 2px;
806
+ border-radius: 1px;
807
+ background: var(--fg-faint);
808
+ transition: width 0.15s, background 0.15s;
809
+ }
810
+
811
+ .msg-nav-tick:hover i {
812
+ background: var(--fg-soft);
813
+ }
814
+
815
+ .msg-nav-tick.on i {
816
+ width: 16px;
817
+ background: var(--fg);
818
+ }
819
+
820
+ /* 展开的列表:在横线左边,鼠标移开就收起 */
821
+ .msg-nav-panel {
822
+ position: absolute;
823
+ right: calc(100% + 4px);
824
+ top: 50%;
825
+ transform: translateY(-50%);
826
+ width: 260px;
827
+ max-height: 60vh;
828
+ overflow-y: auto;
829
+ padding: 6px;
830
+ display: flex;
831
+ flex-direction: column;
832
+ gap: 1px;
833
+ background: var(--bg);
834
+ border: 1px solid var(--border);
835
+ border-radius: 12px;
836
+ box-shadow: var(--shadow);
837
+ opacity: 0;
838
+ visibility: hidden;
839
+ transition: opacity 0.12s, visibility 0.12s;
840
+ }
841
+
842
+ /* 键盘 Tab 进来时也展开;鼠标点过之后不因为焦点还在就一直开着 */
843
+ .msg-nav:hover .msg-nav-panel,
844
+ .msg-nav:has(:focus-visible) .msg-nav-panel {
845
+ opacity: 1;
846
+ visibility: visible;
847
+ }
848
+
849
+ .msg-nav-row {
850
+ flex-shrink: 0;
851
+ text-align: left;
852
+ padding: 6px 10px;
853
+ border-radius: 8px;
854
+ font-size: 13px;
855
+ color: var(--fg-soft);
856
+ white-space: nowrap;
857
+ overflow: hidden;
858
+ text-overflow: ellipsis;
859
+ }
860
+
861
+ .msg-nav-row:hover {
862
+ background: var(--bg-hover);
863
+ color: var(--fg);
864
+ }
865
+
866
+ .msg-nav-row.on {
867
+ color: var(--fg);
868
+ font-weight: 500;
869
+ }
870
+
871
+ /* 手机上没地方放,也没有悬停 */
872
+ @media (max-width: 768px) {
873
+ .msg-nav {
874
+ display: none;
875
+ }
876
+ }
877
+
878
+ /* 排队中的消息:「排队中 · 打断并发送」 */
879
+ .queue-note {
880
+ display: flex;
881
+ align-items: center;
882
+ gap: 10px;
883
+ margin-top: -2px;
884
+ font-size: 12px;
885
+ color: var(--fg-faint);
886
+ }
887
+
888
+ .queue-note.dropped {
889
+ color: var(--danger);
890
+ }
891
+
892
+ .queue-jump {
893
+ padding: 2px 8px;
894
+ border-radius: 6px;
895
+ font-size: 12px;
896
+ color: var(--fg-soft);
897
+ border: 1px solid var(--border);
898
+ }
899
+
900
+ .queue-jump:hover {
901
+ color: var(--fg);
902
+ background: var(--bg-hover);
903
+ }