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
@@ -61,8 +61,10 @@ import {
61
61
  takeAttempt,
62
62
  } from "./auth.js";
63
63
  import { canSeeConv, pushOwnedBy, sessionVisibleTo } from "./access.js";
64
+ import { clampLimit, dirsOf, inDir, inRecent, pageOf, parseCursor } from "./conv-pages.js";
64
65
  import { clientIp, cookies, HttpError, isHttps, readBody, readJsonBody, SECURITY_HEADERS, sendJson } from "./http.js";
65
66
  import { handleTasksApi } from "./tasks-api.js";
67
+ import { handleSkillsApi } from "./skills-api.js";
66
68
  import { addSubscription, listSubscriptions, pushToAll, removeSubscription, removeSubscriptionsOf, vapidPublicKey } from "./push.js";
67
69
 
68
70
  export const COOKIE = "aim_session";
@@ -468,6 +470,45 @@ export async function handleApi(req: IncomingMessage, res: ServerResponse, url:
468
470
 
469
471
  // ----- 总览 -----
470
472
  if (p === "/api/bootstrap" && method === "GET") {
473
+ // 会话只给侧栏第一屏要的(见 conv-pages.ts):「最近」的前 recent 棵树、展开着的目录各自的前几棵,
474
+ // 每个飞书机器人最近的一条,再加上正在跑的、等你处理的(侧栏要显示状态)。其余点「展开显示」再拉
475
+ const all = hub.list(visible).filter((c) => !c.hidden);
476
+ const pending = interactions.listPending().filter((i) => visible(getConversation(i.convKey)));
477
+ const recent = pageOf(all.filter(inRecent), null, clampLimit(url.searchParams.get("recent"), 10));
478
+ let open: [string, number][] = [];
479
+ try {
480
+ const raw: unknown = JSON.parse(url.searchParams.get("open") ?? "[]");
481
+ if (Array.isArray(raw)) {
482
+ open = raw
483
+ .slice(0, 50)
484
+ .filter((x): x is [string, unknown] => Array.isArray(x) && typeof x[0] === "string")
485
+ .map(([dir, n]) => [dir, clampLimit(n, 5)]);
486
+ }
487
+ } catch {
488
+ // 参数坏了就当没有展开的目录
489
+ }
490
+ const dirPages = new Map(open.map(([dir, n]) => [dir, pageOf(all.filter(inDir(dir)), null, n)]));
491
+ const byKey = new Map(all.map((c) => [c.key, c]));
492
+ const keep = new Map(recent.conversations.map((c) => [c.key, c]));
493
+ for (const page of dirPages.values()) for (const c of page.conversations) keep.set(c.key, c);
494
+ const asking = new Set(pending.map((i) => i.convKey));
495
+ const bots = new Set<string>();
496
+ for (const c of all) {
497
+ // all 已按活跃时间从新到旧:每个机器人遇到的第一条就是它最近的
498
+ if (c.channel === "feishu" && !bots.has(c.channelId)) {
499
+ bots.add(c.channelId);
500
+ keep.set(c.key, c);
501
+ }
502
+ if (c.busy || asking.has(c.key)) keep.set(c.key, c);
503
+ }
504
+ // 正开着的会话(可能是后面几页里的,重连时页面上还显示着它)
505
+ const current = byKey.get(url.searchParams.get("current") ?? "");
506
+ if (current) keep.set(current.key, current);
507
+ // 分叉来源:提示里要写它的标题
508
+ for (const c of [...keep.values()]) {
509
+ const parent = c.forkOf ? byKey.get(c.forkOf) : undefined;
510
+ if (parent) keep.set(parent.key, parent);
511
+ }
471
512
  sendJson(res, 200, {
472
513
  agents: [...AGENT_IDS].map((id) => {
473
514
  const d = hub.driver(id);
@@ -487,8 +528,13 @@ export async function handleApi(req: IncomingMessage, res: ServerResponse, url:
487
528
  workspaceRoot: config().workspaceRoot,
488
529
  quotaRefreshMin: config().web.quotaRefreshMin,
489
530
  quotaEnabled: config().web.quotaEnabled,
490
- conversations: hub.list(visible),
491
- pending: interactions.listPending().filter((i) => visible(getConversation(i.convKey))),
531
+ conversations: [...keep.values()],
532
+ sidebar: {
533
+ recent: { next: recent.next },
534
+ // 展开着的目录带上翻页游标(没带 next 的目录前端还没拉过)
535
+ dirs: dirsOf(all).map((d) => (dirPages.has(d.dir) ? { ...d, next: dirPages.get(d.dir)!.next } : d)),
536
+ },
537
+ pending,
492
538
  user: publicMe(user),
493
539
  serverTime: Date.now(),
494
540
  });
@@ -527,6 +573,7 @@ export async function handleApi(req: IncomingMessage, res: ServerResponse, url:
527
573
 
528
574
  // ----- 定时任务 -----
529
575
  if (p.startsWith("/api/tasks") && (await handleTasksApi(req, res, p, user))) return;
576
+ if (p.startsWith("/api/skills") && (await handleSkillsApi(req, res, url, user))) return;
530
577
 
531
578
  // ----- 目录选择器:列工作区内的子目录 -----
532
579
  if (p === "/api/dirs" && method === "GET") {
@@ -537,6 +584,21 @@ export async function handleApi(req: IncomingMessage, res: ServerResponse, url:
537
584
  }
538
585
 
539
586
  // ----- 会话 -----
587
+ // 侧栏按页拉:scope=recent(最近)/ dir(某个项目目录)按树分页;bot(某个飞书机器人的全部聊天)平铺
588
+ if (p === "/api/conversations/page" && method === "GET") {
589
+ const all = hub.list(visible).filter((c) => !c.hidden);
590
+ const scope = url.searchParams.get("scope");
591
+ if (scope === "bot") {
592
+ const bot = url.searchParams.get("bot") ?? "";
593
+ sendJson(res, 200, { conversations: all.filter((c) => c.channel === "feishu" && c.channelId === bot).slice(0, 200), next: null });
594
+ return;
595
+ }
596
+ if (scope !== "recent" && scope !== "dir") throw new HttpError(400, "scope 只能是 recent / dir / bot");
597
+ const list = scope === "recent" ? all.filter(inRecent) : all.filter(inDir(url.searchParams.get("dir") ?? ""));
598
+ const before = parseCursor(url.searchParams.get("at"), url.searchParams.get("key"));
599
+ sendJson(res, 200, pageOf(list, before, clampLimit(url.searchParams.get("limit"), 10)));
600
+ return;
601
+ }
540
602
  if (p === "/api/conversations" && method === "GET") {
541
603
  sendJson(res, 200, hub.list(visible));
542
604
  return;
@@ -651,6 +713,8 @@ export async function handleApi(req: IncomingMessage, res: ServerResponse, url:
651
713
  info: hub.info(key),
652
714
  messages,
653
715
  live,
716
+ // 进行中这一轮弹过的卡(已经处理了的也要):重放到 ix 事件时放回原位
717
+ interactions: live.flatMap((e) => (e.ev.type === "ix" ? [interactions.get(e.ev.id)].filter((i) => !!i) : [])),
654
718
  seq,
655
719
  pending: interactions.listPending(key),
656
720
  allowedAgents: allowedAgentsFor(key),
@@ -736,6 +800,10 @@ export async function handleApi(req: IncomingMessage, res: ServerResponse, url:
736
800
  case "stop":
737
801
  text = await hub.stop(key);
738
802
  break;
803
+ // 打断并发送:value 是排队中那条消息的编号
804
+ case "jump":
805
+ text = await hub.jump(key, value);
806
+ break;
739
807
  case "cd":
740
808
  text = await hub.cd(key, value);
741
809
  break;
@@ -844,6 +912,8 @@ export async function handleApi(req: IncomingMessage, res: ServerResponse, url:
844
912
  allowedAgents: hub.enabledAgents().map((d) => d.id),
845
913
  // 带 key 是输入框补全:只列 Web 能用的;不带是设置页排序,全列
846
914
  ...(conv ? { channel: "web" } : {}),
915
+ // 成员看不到机器上装的技能
916
+ ...(admin ? {} : { member: true }),
847
917
  });
848
918
  sendJson(res, 200, { commands });
849
919
  return;
@@ -0,0 +1,68 @@
1
+ // 侧栏会话列表分页:打开页面只拿第一页,点「展开显示」再按页拉。
2
+ //
3
+ // 一页按「树」算:主对话连同从它分叉出来的对话算一条(和前端 sidebar.js 的 forkForest 一样),
4
+ // 按整棵树里最近的活跃时间从新到旧排。翻页用游标(上一页最后一棵树的活跃时间 + 根的 key),
5
+ // 期间有会话变活跃、被删,也不会漏掉或重复一页的边界。
6
+ import type { ConversationInfo } from "../../core/hub.js";
7
+
8
+ export type Cursor = { at: number; key: string };
9
+ export type Page = { conversations: ConversationInfo[]; next: Cursor | null };
10
+ type Tree = { root: ConversationInfo; convs: ConversationInfo[]; at: number };
11
+
12
+ /** 最近:飞书以外的会话(飞书按机器人各占一行,另外给) */
13
+ export const inRecent = (c: ConversationInfo): boolean => c.channel !== "feishu";
14
+ /** 项目:Web 会话按工作目录分组 */
15
+ export const inDir = (dir: string) => (c: ConversationInfo): boolean => c.channel === "web" && c.cwd === dir;
16
+
17
+ /** 按分叉关系成树:父对话也在这一组里的挂到下面,其余的是根。父链绕回自己(数据坏了)就当根 */
18
+ export function forkTrees(list: ConversationInfo[]): Tree[] {
19
+ const byKey = new Map(list.map((c) => [c.key, c]));
20
+ const rootOf = (c: ConversationInfo): ConversationInfo => {
21
+ let cur = c;
22
+ for (let n = 0; n < 50; n++) {
23
+ const parent = cur.forkOf ? byKey.get(cur.forkOf) : undefined;
24
+ if (!parent || parent.key === c.key) return cur;
25
+ cur = parent;
26
+ }
27
+ return c;
28
+ };
29
+ const trees = new Map<string, Tree>();
30
+ for (const c of list) {
31
+ const root = rootOf(c);
32
+ let t = trees.get(root.key);
33
+ if (!t) trees.set(root.key, (t = { root, convs: [], at: 0 }));
34
+ t.convs.push(c);
35
+ t.at = Math.max(t.at, c.activeAt);
36
+ }
37
+ return [...trees.values()].sort((a, b) => b.at - a.at || (a.root.key < b.root.key ? 1 : -1));
38
+ }
39
+
40
+ /** 游标之后的 limit 棵树 */
41
+ export function pageOf(list: ConversationInfo[], before: Cursor | null, limit: number): Page {
42
+ const trees = forkTrees(list);
43
+ const start = before ? trees.findIndex((t) => t.at < before.at || (t.at === before.at && t.root.key < before.key)) : 0;
44
+ if (start < 0) return { conversations: [], next: null };
45
+ const slice = trees.slice(start, start + limit);
46
+ const last = slice[slice.length - 1];
47
+ return {
48
+ conversations: slice.flatMap((t) => t.convs),
49
+ next: last && start + limit < trees.length ? { at: last.at, key: last.root.key } : null,
50
+ };
51
+ }
52
+
53
+ /** 项目目录:每个目录最近的活跃时间(目录行本身要全部显示,会话再按需拉) */
54
+ export function dirsOf(list: ConversationInfo[]): { dir: string; at: number }[] {
55
+ const at = new Map<string, number>();
56
+ for (const c of list) if (c.channel === "web") at.set(c.cwd, Math.max(at.get(c.cwd) ?? 0, c.activeAt));
57
+ return [...at].map(([dir, t]) => ({ dir, at: t })).sort((a, b) => b.at - a.at);
58
+ }
59
+
60
+ export function parseCursor(at: string | null, key: string | null): Cursor | null {
61
+ const n = Number(at);
62
+ return key && Number.isFinite(n) ? { at: n, key } : null;
63
+ }
64
+
65
+ export const clampLimit = (v: unknown, def: number): number => {
66
+ const n = Math.floor(Number(v));
67
+ return Number.isFinite(n) && n > 0 ? Math.min(n, 200) : def;
68
+ };
@@ -89,8 +89,8 @@ export async function readBody(req: IncomingMessage, limit: number): Promise<Buf
89
89
  return Buffer.concat(chunks);
90
90
  }
91
91
 
92
- export async function readJsonBody<T = Record<string, unknown>>(req: IncomingMessage): Promise<T> {
93
- const raw = await readBody(req, 1024 * 1024);
92
+ export async function readJsonBody<T = Record<string, unknown>>(req: IncomingMessage, limit = 1024 * 1024): Promise<T> {
93
+ const raw = await readBody(req, limit);
94
94
  if (raw.length === 0) return {} as T;
95
95
  try {
96
96
  return JSON.parse(raw.toString("utf8")) as T;
@@ -14,6 +14,7 @@ import { GLOBAL_ALWAYS_DENIED, hub, type EventEnvelope } from "../../core/hub.js
14
14
  import { interactions, type Interaction } from "../../core/interactions.js";
15
15
  import { getConversation } from "../../core/store.js";
16
16
  import { taskEvents } from "../../core/tasks.js";
17
+ import { skillEvents } from "../../core/skills.js";
17
18
  import { RUNNING_VERSION } from "../../core/upgrade.js";
18
19
  import type { Settlement } from "../../core/types.js";
19
20
  import { getUser, isAdmin, legacyOwnerId, type User } from "../../core/users.js";
@@ -142,6 +143,8 @@ type Client = {
142
143
  /** 页面在前台,且正看着哪条会话(前端在切会话、切前后台时上报) */
143
144
  visible: boolean;
144
145
  viewing: string | null;
146
+ /** 最后一次收到这个页面的消息(含 ping) */
147
+ seen: number;
145
148
  };
146
149
 
147
150
  /** 每次进程启动换一个:客户端发现变了,就知道 seq 已经从头计数,得整页重拉 */
@@ -186,11 +189,22 @@ function broadcastTasks(owner: string): void {
186
189
  for (const client of clients) if (client.user.id === owner) send(client, { t: "tasks" });
187
190
  }
188
191
 
192
+ /** 技能变了:以前装过别人技能的人也受影响,通知所有人重新拉(列表很小) */
193
+ function broadcastSkills(): void {
194
+ for (const client of clients) send(client, { t: "skills" });
195
+ }
196
+
189
197
  // ---------- 系统通知(Web Push) ----------
190
198
 
191
- /** 有页面正在前台看着这条会话:人就在眼前,不用推 */
199
+ /**
200
+ * 有页面正在前台看着这条会话:人就在眼前,不用推。
201
+ * 页面每 25 秒发一次 ping;40 秒没收到它的任何消息就不算(手机切走后网络断了,连接还没被心跳清掉,
202
+ * 最后报的还是「在看」)
203
+ */
204
+ const SEEN_MS = 40_000;
192
205
  function watching(key: string): boolean {
193
- for (const client of clients) if (client.visible && client.viewing === key) return true;
206
+ const now = Date.now();
207
+ for (const client of clients) if (client.visible && client.viewing === key && now - client.seen < SEEN_MS) return true;
194
208
  return false;
195
209
  }
196
210
 
@@ -269,6 +283,7 @@ function handleClientMessage(client: Client, raw: string): void {
269
283
  // 每条消息都复查登录态:握手时有效不代表现在还有效(改了密码、被踢了)
270
284
  const user = checkClient(client);
271
285
  if (!user) return;
286
+ client.seen = Date.now();
272
287
  let msg: Record<string, unknown>;
273
288
  try {
274
289
  const parsed: unknown = JSON.parse(raw);
@@ -442,7 +457,7 @@ export async function startWeb(): Promise<void> {
442
457
  }
443
458
  const token = cookies(req)[COOKIE] ?? "";
444
459
  wss!.handleUpgrade(req, socket, head, (ws) => {
445
- const client: Client = { ws, subs: new Set(), alive: true, token, user, visible: false, viewing: null };
460
+ const client: Client = { ws, subs: new Set(), alive: true, token, user, visible: false, viewing: null, seen: Date.now() };
446
461
  clients.add(client);
447
462
  ws.on("pong", () => (client.alive = true));
448
463
  ws.on("message", (data) => {
@@ -485,6 +500,7 @@ export async function startWeb(): Promise<void> {
485
500
  interactions.on("updated", broadcastInteraction);
486
501
  interactions.on("settled", broadcastInteraction);
487
502
  taskEvents.on("changed", broadcastTasks);
503
+ skillEvents.on("changed", broadcastSkills);
488
504
 
489
505
  await listen(server, host, port);
490
506
  console.log(`[web] 已监听 http://${host}:${port}`);
@@ -515,6 +531,7 @@ export async function stopWeb(): Promise<void> {
515
531
  interactions.off("updated", broadcastInteraction);
516
532
  interactions.off("settled", broadcastInteraction);
517
533
  taskEvents.off("changed", broadcastTasks);
534
+ skillEvents.off("changed", broadcastSkills);
518
535
  if (heartbeat) clearInterval(heartbeat);
519
536
  for (const client of clients) client.ws.close(1001, "服务重启");
520
537
  clients.clear();
@@ -0,0 +1,225 @@
1
+ // 技能页面的接口:/api/skills。看得到的技能(自己的、公开的、指名给自己的)、安装 / 停用、变量、上传。
2
+ // 只有作者能改;管理员能删别人公开或分享出来的(下架),但看不到、删不了别人私有的。
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import type { IncomingMessage, ServerResponse } from "node:http";
6
+ import { machineSkills, mentionTargets } from "../../core/skill-runtime.js";
7
+ import {
8
+ canDelete,
9
+ canSee,
10
+ createSkill,
11
+ declaredHosts,
12
+ deleteSkill,
13
+ getSkill,
14
+ installCount,
15
+ installSkill,
16
+ listAllSkills,
17
+ listFiles,
18
+ parseSkillMd,
19
+ prepareUpload,
20
+ readSkillFile,
21
+ readSkillMd,
22
+ setSkillEnabled,
23
+ setSkillVars,
24
+ SKILL_LIMITS,
25
+ updateSkill,
26
+ userPrefs,
27
+ type Skill,
28
+ type SkillInput,
29
+ } from "../../core/skills.js";
30
+ import { readZip } from "../../core/zip.js";
31
+ import { getUser, isAdmin, listUsers, type User } from "../../core/users.js";
32
+ import { HttpError, readJsonBody, sendJson } from "./http.js";
33
+
34
+ function view(s: Skill, user: User) {
35
+ const prefs = userPrefs(user.id);
36
+ const mine = s.owner === user.id;
37
+ return {
38
+ id: s.id,
39
+ name: s.name,
40
+ description: s.description,
41
+ owner: s.owner,
42
+ ownerName: getUser(s.owner)?.name ?? "(已删除的用户)",
43
+ mine,
44
+ visibility: s.visibility,
45
+ // 分享给了谁只有作者看得到
46
+ ...(mine ? { sharedWith: s.sharedWith, installs: installCount(s.id) } : {}),
47
+ vars: s.vars,
48
+ source: s.source,
49
+ createdAt: s.createdAt,
50
+ updatedAt: s.updatedAt,
51
+ installed: mine || prefs.installed.includes(s.id),
52
+ enabled: !prefs.disabled.includes(s.id),
53
+ canDelete: canDelete({ id: user.id, role: user.role }, s),
54
+ };
55
+ }
56
+
57
+ /**
58
+ * 变量:值按名字存;密钥(任何一个看得到的技能把它标成密钥)不回显,只说填没填。
59
+ * 受保护的密钥再带上确认过的地址(hosts),作者后来加了没确认过的地址就列在 pending 里。
60
+ */
61
+ function varsView(user: User, skills: Skill[]) {
62
+ const secret = new Set(skills.flatMap((s) => s.vars.filter((v) => v.secret).map((v) => v.name)));
63
+ const prefs = userPrefs(user.id);
64
+ const mineOrInstalled = skills.filter((s) => s.owner === user.id || prefs.installed.includes(s.id));
65
+ return Object.fromEntries(
66
+ Object.entries(prefs.vars).map(([k, v]) => {
67
+ const declared = declaredHosts(mineOrInstalled, k);
68
+ const approved = prefs.approved[k] ?? [];
69
+ const pending = declared.filter((h) => !approved.includes(h));
70
+ return [
71
+ k,
72
+ {
73
+ set: true,
74
+ ...(secret.has(k) ? {} : { value: v }),
75
+ ...(declared.length ? { hosts: approved } : {}),
76
+ ...(pending.length ? { pending } : {}),
77
+ },
78
+ ];
79
+ }),
80
+ );
81
+ }
82
+
83
+ type UploadBody = { zip?: string; files?: { path?: string; data?: string }[] };
84
+
85
+ /** 上传:zip(.zip / .skill)或者一堆文件(选文件夹 / 单个 SKILL.md),内容都是 base64 */
86
+ function uploadOf(raw: UploadBody | undefined): ReturnType<typeof prepareUpload> | undefined {
87
+ if (!raw) return undefined;
88
+ if (typeof raw.zip === "string") {
89
+ const entries = readZip(Buffer.from(raw.zip, "base64"), SKILL_LIMITS.totalBytes * 2, SKILL_LIMITS.files * 2);
90
+ return prepareUpload(entries);
91
+ }
92
+ if (Array.isArray(raw.files)) {
93
+ if (raw.files.length > SKILL_LIMITS.files * 2) throw new Error(`文件太多(最多 ${SKILL_LIMITS.files} 个)`);
94
+ return prepareUpload(raw.files.map((f) => ({ path: String(f.path ?? ""), data: Buffer.from(String(f.data ?? ""), "base64") })));
95
+ }
96
+ throw new Error("上传的内容不对");
97
+ }
98
+
99
+ const bad = (err: unknown) => (err instanceof HttpError ? err : new HttpError(400, err instanceof Error ? err.message : String(err)));
100
+
101
+ /** 处理了就返回 true */
102
+ export async function handleSkillsApi(req: IncomingMessage, res: ServerResponse, url: URL, user: User): Promise<boolean> {
103
+ const method = req.method ?? "GET";
104
+ const p = url.pathname;
105
+ const viewer = { id: user.id, role: user.role };
106
+
107
+ if (p === "/api/skills" && method === "GET") {
108
+ const visible = listAllSkills().filter((s) => canSee(viewer, s));
109
+ sendJson(res, 200, {
110
+ skills: visible.map((s) => view(s, user)),
111
+ vars: varsView(user, visible),
112
+ // 分享给谁:列出其他还能登录的用户
113
+ users: listUsers()
114
+ .filter((u) => !u.disabled && u.id !== user.id)
115
+ .map((u) => ({ id: u.id, name: u.name })),
116
+ ...(isAdmin(user) ? { machine: machineSkills().map((m) => ({ name: m.name, description: m.description, dir: m.dir, agents: m.agents })) } : {}),
117
+ limits: SKILL_LIMITS,
118
+ });
119
+ return true;
120
+ }
121
+
122
+ // 输入框 @ 的补全:自己能用的(管理员加上机器上原有的)
123
+ if (p === "/api/skills/mention" && method === "GET") {
124
+ sendJson(res, 200, { skills: mentionTargets(user.id).map((t) => ({ name: t.name, description: t.description, kind: t.kind })) });
125
+ return true;
126
+ }
127
+
128
+ // 管理员看机器上原有技能的 SKILL.md
129
+ if (p === "/api/skills/machine" && method === "GET") {
130
+ if (!isAdmin(user)) throw new HttpError(403, "只有管理员能看机器上的技能");
131
+ const m = machineSkills().find((x) => x.name === url.searchParams.get("name"));
132
+ if (!m) throw new HttpError(404, "没有这个技能");
133
+ let md = "";
134
+ try {
135
+ md = fs.readFileSync(path.join(m.dir, "SKILL.md"), "utf8");
136
+ } catch {
137
+ md = "(读不到 SKILL.md)";
138
+ }
139
+ sendJson(res, 200, { ...m, skillMd: md });
140
+ return true;
141
+ }
142
+
143
+ if (p === "/api/skills/vars" && method === "PUT") {
144
+ const body = await readJsonBody<{ values?: Record<string, unknown>; approve?: unknown }>(req);
145
+ try {
146
+ setSkillVars(user.id, body.values ?? {}, Array.isArray(body.approve) ? body.approve.map(String) : []);
147
+ } catch (err) {
148
+ throw bad(err);
149
+ }
150
+ sendJson(res, 200, { vars: varsView(user, listAllSkills().filter((s) => canSee(viewer, s))) });
151
+ return true;
152
+ }
153
+
154
+ if (p === "/api/skills" && method === "POST") {
155
+ const body = await readJsonBody<SkillInput & { upload?: UploadBody }>(req, 16 * 1024 * 1024);
156
+ let skill: Skill;
157
+ try {
158
+ const upload = uploadOf(body.upload);
159
+ const { upload: _u, ...input } = body;
160
+ skill = createSkill(user.id, input, upload ? "upload" : "manual", upload);
161
+ } catch (err) {
162
+ throw bad(err);
163
+ }
164
+ sendJson(res, 200, view(skill, user));
165
+ return true;
166
+ }
167
+
168
+ const m = /^\/api\/skills\/(s_[a-f0-9]+)(?:\/(install|enable|file))?$/.exec(p);
169
+ if (!m) return false;
170
+ const skill = getSkill(m[1]!);
171
+ if (!skill || !canSee(viewer, skill)) throw new HttpError(404, "这个技能不存在");
172
+
173
+ if (!m[2] && method === "GET") {
174
+ const md = parseSkillMd(readSkillMd(skill.id));
175
+ sendJson(res, 200, { ...view(skill, user), body: md.body, extra: md.extra, files: listFiles(skill.id) });
176
+ return true;
177
+ }
178
+ if (m[2] === "file" && method === "GET") {
179
+ const data = readSkillFile(skill.id, url.searchParams.get("path") ?? "");
180
+ if (!data) throw new HttpError(404, "没有这个文件");
181
+ // 只当文本看:二进制的给个提示
182
+ const text = data.includes(0) ? null : data.toString("utf8");
183
+ sendJson(res, 200, { text, size: data.length });
184
+ return true;
185
+ }
186
+ if (m[2] === "install" && method === "POST") {
187
+ const body = await readJsonBody<{ on?: boolean }>(req);
188
+ try {
189
+ installSkill(user.id, skill.id, body.on !== false);
190
+ } catch (err) {
191
+ throw bad(err);
192
+ }
193
+ sendJson(res, 200, view(skill, user));
194
+ return true;
195
+ }
196
+ if (m[2] === "enable" && method === "POST") {
197
+ const body = await readJsonBody<{ on?: boolean }>(req);
198
+ setSkillEnabled(user.id, skill.id, body.on !== false);
199
+ sendJson(res, 200, view(skill, user));
200
+ return true;
201
+ }
202
+ if (!m[2] && method === "PATCH") {
203
+ const body = await readJsonBody<SkillInput & { upload?: UploadBody }>(req, 16 * 1024 * 1024);
204
+ let updated: Skill;
205
+ try {
206
+ const upload = uploadOf(body.upload);
207
+ const { upload: _u, name: _n, ...input } = body;
208
+ updated = updateSkill(user.id, skill.id, input, upload ? { upload } : {});
209
+ } catch (err) {
210
+ throw bad(err);
211
+ }
212
+ sendJson(res, 200, view(updated, user));
213
+ return true;
214
+ }
215
+ if (!m[2] && method === "DELETE") {
216
+ try {
217
+ deleteSkill(viewer, skill.id);
218
+ } catch (err) {
219
+ throw bad(err);
220
+ }
221
+ sendJson(res, 200, { ok: true });
222
+ return true;
223
+ }
224
+ return false;
225
+ }
@@ -36,13 +36,13 @@ export function chatSystemPrompt(search: string, memory?: string): string {
36
36
  const OPEN = "<agent-loft-context>";
37
37
  const CLOSE = "</agent-loft-context>";
38
38
 
39
- /** Codex:附在会话进程第一条消息后面的背景(模式 + 记忆) */
40
- export function contextBlock(mode: "chat" | "work", memory: string | undefined, search: string): string {
39
+ /** Codex:附在会话进程第一条消息后面的背景(模式 + 记忆 + 工作模式下的技能清单) */
40
+ export function contextBlock(mode: "chat" | "work", memory: string | undefined, search: string, skills: string[] = []): string {
41
41
  const head =
42
42
  mode === "chat"
43
43
  ? ["当前是「聊天模式」,你作为用户的私人 AI 助手和他聊天:", ...chatRules(search)]
44
44
  : ["当前是「工作模式」:可以执行命令、读写文件,按平常的方式干活。之前如果说过「聊天模式下不能做」,那个限制已经解除。"];
45
- return wrapContext([...head, ...(memory ? ["", memory] : [])]);
45
+ return wrapContext([...head, ...(memory ? ["", memory] : []), ...(mode === "work" && skills.length ? ["", ...skills] : [])]);
46
46
  }
47
47
 
48
48
  /** 包成 agent-loft 附上的背景:用户看不到(读历史、实时显示时都剥掉),agent 照着做 */
@@ -24,6 +24,7 @@ export const BUILTIN_COMMANDS: BuiltinSpec[] = [
24
24
  { name: "effort", args: "[程度]", description: "切换思考程度" },
25
25
  { name: "chat", description: "切到聊天模式:精简、只能联网搜索,每轮开销小很多" },
26
26
  { name: "work", description: "切到工作模式:agent 原装的全部能力" },
27
+ { name: "skills", description: "列出能用的技能(消息里写 @名字 点名使用)" },
27
28
  { name: "status", description: "查看当前会话、模型、目录、剩余额度" },
28
29
  { name: "sessions", args: "[all]", description: "列出历史会话,点按钮恢复;加 all 列所有目录" },
29
30
  { name: "resume", args: "<会话ID>", description: "恢复指定会话" },
@@ -42,7 +43,13 @@ export const BUILTIN_COMMANDS: BuiltinSpec[] = [
42
43
  ];
43
44
 
44
45
  /** agent 自己报上来的命令 */
45
- export type AgentCommand = { name: string; description: string; args?: string };
46
+ export type AgentCommand = {
47
+ name: string;
48
+ description: string;
49
+ args?: string;
50
+ /** agent 自己带的(Claude Code 的 /compact、自带的技能),不是机器上另外装的 */
51
+ builtin?: boolean;
52
+ };
46
53
 
47
54
  /**
48
55
  * 用户自定义命令。prompt 里的 $ARGUMENTS 换成命令后面跟的参数;
@@ -20,6 +20,7 @@ export type ParsedCommand =
20
20
  | { kind: "autoedit"; on?: boolean }
21
21
  | { kind: "mode"; mode: "chat" | "work" }
22
22
  | { kind: "help" }
23
+ | { kind: "skills" }
23
24
  /** 只发了一个「/」:列出可用命令 */
24
25
  | { kind: "commands" }
25
26
  | { kind: "approve"; requestId: string; decision: ApprovalDecision }
@@ -75,6 +76,8 @@ export function parseCommand(raw: string): ParsedCommand {
75
76
  return arg ? { kind: "chat", text } : { kind: "mode", mode: head.toLowerCase() as "chat" | "work" };
76
77
  case "help":
77
78
  return { kind: "help" };
79
+ case "skills":
80
+ return arg ? { kind: "chat", text } : { kind: "skills" };
78
81
  case "approve": {
79
82
  const [requestId, decision] = rest;
80
83
  if (!requestId || !decision) return { kind: "chat", text };