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
package/src/core/hub.ts CHANGED
@@ -11,6 +11,7 @@ import crypto from "node:crypto";
11
11
  import { EventEmitter } from "node:events";
12
12
  import { config, onConfigChange, resolveWorkspacePath } from "./config.js";
13
13
  import { attachFooters, copyFooters, formatDuration, recordFooter } from "./footers.js";
14
+ import { flushUsage, recordUsage, usageOf, type UsageTotals } from "./usage.js";
14
15
  import { interactions, SETTLEMENT_LABEL } from "./interactions.js";
15
16
  import {
16
17
  agentBinding,
@@ -46,7 +47,9 @@ import type {
46
47
  import { buildCatalog, expandCustomCommand, type AgentCommand, type CommandEntry, type CommandPrefs } from "./command-catalog.js";
47
48
  import { helpText, type ParsedCommand } from "./commands.js";
48
49
  import { listScheduled } from "./scheduled.js";
49
- import { stripContext } from "./chat-mode.js";
50
+ import { stripContext, wrapContext } from "./chat-mode.js";
51
+ import { isMachineCommand, mentionContext, sessionSkills, skillsText } from "./skill-runtime.js";
52
+ import { skillEvents } from "./skills.js";
50
53
  import { isSilentReply } from "./task-prompt.js";
51
54
 
52
55
  // ---------- 事件 ----------
@@ -63,7 +66,15 @@ export type Origin = {
63
66
  };
64
67
 
65
68
  export type ConvEvent =
66
- | { type: "user"; text: string; attachments: string[]; files?: AttachmentInfo[]; origin: Origin }
69
+ /** id:这条消息的编号,后面的 queue 事件按它对上;queued:收到时前面还有在跑 / 在等的,要排队 */
70
+ | { type: "user"; text: string; attachments: string[]; files?: AttachmentInfo[]; origin: Origin; id?: string; queued?: boolean }
71
+ /** 排队的消息:轮到它了(started,或者被当成提问的回答用掉了),或者没发出去(dropped:停止、会话切换…) */
72
+ | { type: "queue"; id: string; state: "started" | "dropped" }
73
+ /**
74
+ * 这里弹出过一张授权 / 提问卡(内容和状态在 interactions 里按 id 查)。只是给卡片在对话里占个位置:
75
+ * 刷新页面时进行中的这一轮靠重放事件画出来,没有它卡片就丢了
76
+ */
77
+ | { type: "ix"; id: string }
67
78
  | { type: "turn_start" }
68
79
  | { type: "step"; kind: StepKind; text: string }
69
80
  | { type: "delta"; text: string }
@@ -139,6 +150,8 @@ export type ConversationInfo = {
139
150
  runtimeEffort?: string;
140
151
  /** 当前 agent 会话的上下文占用(这次启动以来跑过至少一轮才有) */
141
152
  context?: ContextUsage;
153
+ /** 当前 agent 会话累计消耗的 token(升级后跑过至少一轮才有) */
154
+ usage?: UsageTotals;
142
155
  busy: boolean;
143
156
  activeAt: number;
144
157
  createdAt: number;
@@ -188,6 +201,14 @@ class Hub extends EventEmitter<{ event: [EventEnvelope]; task: [TaskTurnEnd] }>
188
201
  private seq = 0;
189
202
  /** 每条会话还没收到结果的轮数,> 0 即忙 */
190
203
  private inflight = new Map<string, number>();
204
+ /**
205
+ * 已经交给 agent 会话、还没出结果的消息,和 inflight、turnTags 一一对应:第一条在跑,后面的在排队。
206
+ * 用来告诉页面哪条消息轮到了 / 被丢了,以及「打断并发送」时把哪条挪到前面
207
+ */
208
+ private turnFifo = new Map<string, { id: string; input: UserInput; queued: boolean }[]>();
209
+ /** 收到了、还在串行队列里没交给 agent 的消息数:这时再来的消息也算排队 */
210
+ private waitingSends = new Map<string, number>();
211
+ private msgSeq = 0;
191
212
  private announced = new Set<string>();
192
213
  /** 每条会话因缓冲区溢出被丢掉的最后一个 seq */
193
214
  private truncated = new Map<string, number>();
@@ -227,6 +248,9 @@ class Hub extends EventEmitter<{ event: [EventEnvelope]; task: [TaskTurnEnd] }>
227
248
  constructor() {
228
249
  super();
229
250
  this.setMaxListeners(100);
251
+ interactions.on("created", (i) => this.emitEvent(i.convKey, { type: "ix", id: i.id }));
252
+ // 技能装了、改了、停用了、变量改了:受影响的人空闲的会话换上新的
253
+ skillEvents.on("changed", (users) => this.personalChanged(new Set(users)));
230
254
  // workspaceRoot 改了:store 会把没显式 /cd 过的会话目录重置,正在跑的旧目录会话要作废
231
255
  onConfigChange((next, prev) => {
232
256
  // 默认模型可能改了,缓存作废,下次显示时重新读
@@ -457,6 +481,10 @@ class Hub extends EventEmitter<{ event: [EventEnvelope]; task: [TaskTurnEnd] }>
457
481
  const ctx = binding.sessionId ? this.contextBySession.get(binding.sessionId) : undefined;
458
482
  return ctx ? { context: ctx } : {};
459
483
  })(),
484
+ ...(() => {
485
+ const usage = usageOf(binding.sessionId);
486
+ return usage ? { usage } : {};
487
+ })(),
460
488
  busy: this.isBusy(key),
461
489
  activeAt: conv.activeAt,
462
490
  createdAt: conv.createdAt,
@@ -520,6 +548,8 @@ class Hub extends EventEmitter<{ event: [EventEnvelope]; task: [TaskTurnEnd] }>
520
548
  const memory = sessionMemory(conv);
521
549
  // 定时任务工具:和记忆一样只给有主人的对话(飞书群聊里谁都能让机器人建任务,算不清是谁的)
522
550
  const taskOwner = memoryOwnerOf(conv);
551
+ // 技能也跟着主人走:他启用的技能、填的变量;成员看不到机器上原有的技能(见 skill-runtime.ts)
552
+ const skills = sessionSkills(taskOwner);
523
553
  const mode = conv.mode ?? "work";
524
554
  // 续着的会话上次被告知的模式和现在不一样(新会话没有历史,算原装的工作模式)
525
555
  const announceMode = (resumeId ? (binding.announcedMode ?? "work") : "work") !== mode;
@@ -543,6 +573,7 @@ class Hub extends EventEmitter<{ event: [EventEnvelope]; task: [TaskTurnEnd] }>
543
573
  ...(memory ? { memory } : {}),
544
574
  ...(announceMode ? { announceMode: true } : {}),
545
575
  ...(taskOwner ? { tasks: { userId: taskOwner, convKey: key } } : {}),
576
+ ...(skills ? { skills } : {}),
546
577
  },
547
578
  {
548
579
  onTurnStart: () => {
@@ -574,6 +605,7 @@ class Hub extends EventEmitter<{ event: [EventEnvelope]; task: [TaskTurnEnd] }>
574
605
  const conv = getConversation(key);
575
606
  recordFooter(holder.session?.sessionId ?? (conv ? agentBinding(conv, agent).sessionId : undefined), result.text, footer);
576
607
  this.setInflight(key, (this.inflight.get(key) ?? 1) - 1);
608
+ this.finishTurn(key);
577
609
  this.refreshRuntimeOnce(key, liveId);
578
610
  this.syncTitle(key, agent);
579
611
  this.dropIfStale(key);
@@ -600,6 +632,7 @@ class Hub extends EventEmitter<{ event: [EventEnvelope]; task: [TaskTurnEnd] }>
600
632
  this.emitEvent(key, { type: "error", message: diagnosis.chatMessage });
601
633
  }
602
634
  this.setInflight(key, (this.inflight.get(key) ?? 1) - 1);
635
+ this.finishTurn(key);
603
636
  this.dropIfStale(key);
604
637
  },
605
638
  onContext: (ctx) => {
@@ -615,6 +648,8 @@ class Hub extends EventEmitter<{ event: [EventEnvelope]; task: [TaskTurnEnd] }>
615
648
  // Codex 一轮里会报好几次,数字没变就不打扰前端
616
649
  if (prev?.used !== ctx.used || prev?.window !== ctx.window) this.emitEvent(key, { type: "meta" });
617
650
  },
651
+ // 不用另发 meta:一轮结束的 result / error 会把会话信息(含用量)推给页面
652
+ onUsage: (usage, cumulative) => alive() && recordUsage(holder.session?.sessionId, usage, cumulative),
618
653
  onDropped: (n) => {
619
654
  if (!alive() || n <= 0) return;
620
655
  // 丢的是排在最后、还没开始的那 n 条(Codex 先报正在跑的那轮出错、再报丢弃,那时队头已经取走了)
@@ -623,6 +658,10 @@ class Hub extends EventEmitter<{ event: [EventEnvelope]; task: [TaskTurnEnd] }>
623
658
  if (tag) this.emit("task", { ...tag, key, status: "interrupted", text: "排队时被停止了" });
624
659
  }
625
660
  this.setInflight(key, (this.inflight.get(key) ?? n) - n);
661
+ // 第一条是在跑的那一轮(它自己会报结果),丢的只会是后面排队的
662
+ const fifo = this.turnFifo.get(key) ?? [];
663
+ const k = Math.min(n, Math.max(0, fifo.length - 1));
664
+ for (const e of fifo.splice(fifo.length - k, k)) this.emitEvent(key, { type: "queue", id: e.id, state: "dropped" });
626
665
  },
627
666
  onSessionId: (sessionId) => {
628
667
  // 切走之后旧会话晚到的回调不该覆盖新绑定
@@ -696,6 +735,7 @@ class Hub extends EventEmitter<{ event: [EventEnvelope]; task: [TaskTurnEnd] }>
696
735
  this.live.delete(key);
697
736
  for (const tag of this.turnTags.get(key) ?? []) if (tag) this.emit("task", { ...tag, key, status: "interrupted", text: note });
698
737
  this.turnTags.delete(key);
738
+ this.clearFifo(key);
699
739
  this.setInflight(key, 0);
700
740
  if (!existing) return;
701
741
  // dispose 卡住(CLI 不响应)不能把这条会话的操作队列永远堵死
@@ -824,6 +864,8 @@ class Hub extends EventEmitter<{ event: [EventEnvelope]; task: [TaskTurnEnd] }>
824
864
  attachments: input.attachments.map((a) => `${KIND_LABEL[a.kind]} ${a.name}`),
825
865
  files: input.attachments.map(attachmentInfo),
826
866
  origin,
867
+ id: `m${++this.msgSeq}`,
868
+ queued: false,
827
869
  };
828
870
  const by = `${origin.name ?? "用户"}以消息作答`;
829
871
 
@@ -858,27 +900,70 @@ class Hub extends EventEmitter<{ event: [EventEnvelope]; task: [TaskTurnEnd] }>
858
900
  updateConversation(key, { title: shown.text.trim().replace(/\s+/g, " ").slice(0, 40), titleAuto: true });
859
901
  this.emitEvent(key, { type: "meta" });
860
902
  }
903
+ // 前面有在跑的、或者还在串行队列里等着的:这条要排队
904
+ shown.queued = this.isBusy(key) || (this.waitingSends.get(key) ?? 0) > 0;
861
905
  this.emitEvent(key, shown);
906
+ const dropped = () => this.emitEvent(key, { type: "queue", id: shown.id, state: "dropped" });
862
907
  const gone = (text: string) => {
908
+ dropped();
863
909
  if (task) this.emit("task", { ...task, key, status: "interrupted", text });
864
910
  };
911
+ this.waitingSends.set(key, (this.waitingSends.get(key) ?? 0) + 1);
865
912
  void this.serial(key, async () => {
866
- if (cancelled()) return gone("没有发出(停止了或者会话被切换了)");
867
- if (questionAtEntry && tryAnswer(questionAtEntry)) return;
868
- // 收到之后发生过显式切换(恢复 / 分叉 / 新会话…):用户已经明确指定了会话,不再按旧的闲置时间作废它。
869
- // 定时任务也不按闲置过期:每天跑一次的任务要接着上次的上下文
870
- const ttl = config().sessionTtlMs;
871
- const switchedAfterEntry = (this.lastSwitch.get(key)?.at ?? 0) >= g.at;
872
- const expired = !task && !switchedAfterEntry && ttl > 0 && Date.now() - lastActive > ttl;
873
- if (expired) {
874
- await this.expireNow(key);
875
- // 清理要等旧会话 dispose,期间可能又点了停止
876
- if (cancelled()) return;
913
+ try {
914
+ if (cancelled()) return gone("没有发出(停止了或者会话被切换了)");
915
+ if (questionAtEntry && tryAnswer(questionAtEntry)) {
916
+ // 被当成提问的回答用掉了:不再排队
917
+ if (shown.queued) this.emitEvent(key, { type: "queue", id: shown.id, state: "started" });
918
+ return;
919
+ }
920
+ // 收到之后发生过显式切换(恢复 / 分叉 / 新会话…):用户已经明确指定了会话,不再按旧的闲置时间作废它。
921
+ // 定时任务也不按闲置过期:每天跑一次的任务要接着上次的上下文
922
+ const ttl = config().sessionTtlMs;
923
+ const switchedAfterEntry = (this.lastSwitch.get(key)?.at ?? 0) >= g.at;
924
+ const expired = !task && !switchedAfterEntry && ttl > 0 && Date.now() - lastActive > ttl;
925
+ if (expired) {
926
+ await this.expireNow(key);
927
+ // 清理要等旧会话 dispose,期间可能又点了停止
928
+ if (cancelled()) return dropped();
929
+ }
930
+ const blocked = await this.blockedSlash(key, input.text);
931
+ if (blocked) {
932
+ this.emitEvent(key, { type: "notice", text: blocked });
933
+ return gone(blocked);
934
+ }
935
+ this.sendNow(key, this.withMentions(key, input), task ?? null, { id: shown.id, queued: shown.queued });
936
+ } finally {
937
+ this.waitingSends.set(key, Math.max(0, (this.waitingSends.get(key) ?? 1) - 1));
877
938
  }
878
- this.sendNow(key, input, task ?? null);
879
939
  });
880
940
  }
881
941
 
942
+ /**
943
+ * 消息里 @ 了技能:附一段背景告诉 agent 用哪个、怎么用(用户看不到,见 skill-runtime.ts 的 mentionContext)。
944
+ * 按发出去这一刻对话的 agent 和模式拼:排队期间可能刚切过。
945
+ */
946
+ private withMentions(key: string, input: UserInput): UserInput {
947
+ const conv = getConversation(key);
948
+ if (!conv) return input;
949
+ const lines = mentionContext(stripContext(input.text), { owner: memoryOwnerOf(conv), agent: conv.agent, mode: conv.mode ?? "work" });
950
+ return lines ? { ...input, text: `${input.text}\n\n${wrapContext(lines)}` } : input;
951
+ }
952
+
953
+ /** 成员直接发「/机器上的技能」:Claude 会绕过技能白名单把它展开,这里先拦下。返回拦下的原因 */
954
+ private async blockedSlash(key: string, text: string): Promise<string | null> {
955
+ const m = /^[//](\S+)/.exec(text.trim());
956
+ if (!m) return null;
957
+ const conv = getConversation(key);
958
+ if (!conv || conv.agent !== "claude" || conv.mode === "chat") return null;
959
+ const owner = memoryOwnerOf(conv);
960
+ if (!owner || getUser(owner)?.role === "admin") return null;
961
+ const driver = this.enabledAgents().find((d) => d.id === conv.agent);
962
+ if (!driver?.commands) return null;
963
+ const native = await withTimeout(driver.commands(conv.cwd), "读取命令列表").catch(() => [] as AgentCommand[]);
964
+ return isMachineCommand(m[1]!, native, conv.cwd) ? `/${m[1]} 是这台机器上装的技能,只有管理员能用。` : null;
965
+ }
966
+
882
967
  /** 刚点过停止(被打断的那一轮还可能晚到一个出错,见 onError 里的 stopped) */
883
968
  recentlyStopped(key: string): boolean {
884
969
  return Date.now() - (this.stoppedAt.get(key) ?? 0) < 15_000;
@@ -909,7 +994,8 @@ class Hub extends EventEmitter<{ event: [EventEnvelope]; task: [TaskTurnEnd] }>
909
994
  this.emitEvent(key, { type: "meta" });
910
995
  }
911
996
 
912
- private sendNow(key: string, input: UserInput, tag: TaskTurn | null): void {
997
+ /** msg:页面上那条消息的编号和收到时是否排队(见 send 里的 shown) */
998
+ private sendNow(key: string, input: UserInput, tag: TaskTurn | null, msg: { id: string; queued: boolean }): void {
913
999
  const fail = (err: unknown) => {
914
1000
  const message = err instanceof Error ? err.message : String(err);
915
1001
  this.emitEvent(key, { type: "error", message });
@@ -926,12 +1012,22 @@ class Hub extends EventEmitter<{ event: [EventEnvelope]; task: [TaskTurnEnd] }>
926
1012
  const tags = this.turnTags.get(key) ?? [];
927
1013
  tags.push(tag);
928
1014
  this.turnTags.set(key, tags);
1015
+ const fifo = this.turnFifo.get(key) ?? [];
1016
+ fifo.push({ id: msg.id, input, queued: msg.queued });
1017
+ this.turnFifo.set(key, fifo);
929
1018
  try {
930
1019
  session.send(input);
931
1020
  } catch (err) {
932
1021
  tags.pop();
1022
+ fifo.pop();
933
1023
  this.setInflight(key, (this.inflight.get(key) ?? 1) - 1);
934
1024
  fail(err);
1025
+ return;
1026
+ }
1027
+ // 收到时在排队(前面的在串行队列里等着),交出去时前面已经空了:这就开始了
1028
+ if (fifo.length === 1 && msg.queued) {
1029
+ fifo[0]!.queued = false;
1030
+ this.emitEvent(key, { type: "queue", id: msg.id, state: "started" });
935
1031
  }
936
1032
  }
937
1033
 
@@ -1140,7 +1236,7 @@ class Hub extends EventEmitter<{ event: [EventEnvelope]; task: [TaskTurnEnd] }>
1140
1236
  this.staleLive.add(live.id);
1141
1237
  return;
1142
1238
  }
1143
- await this.drop(key, "记忆已更新");
1239
+ await this.drop(key, "记忆或技能已更新");
1144
1240
  });
1145
1241
  }
1146
1242
 
@@ -1149,9 +1245,15 @@ class Hub extends EventEmitter<{ event: [EventEnvelope]; task: [TaskTurnEnd] }>
1149
1245
  * agent 自己用 remember / forget 改的不走这里 —— 那些本来就在上下文里,没必要重启进程、白白丢掉提示缓存。
1150
1246
  */
1151
1247
  memoryChanged(userId: string): void {
1248
+ this.personalChanged(new Set([userId]));
1249
+ }
1250
+
1251
+ /** 这些人的记忆或技能变了:他们名下活着的会话空闲下来就重新拉起 */
1252
+ private personalChanged(users: Set<string>): void {
1152
1253
  for (const [key, live] of this.live) {
1153
1254
  const conv = getConversation(key);
1154
- if (!conv || memoryOwnerOf(conv) !== userId) continue;
1255
+ const owner = conv ? memoryOwnerOf(conv) : undefined;
1256
+ if (!owner || !users.has(owner)) continue;
1155
1257
  this.staleLive.add(live.id);
1156
1258
  this.dropIfStale(key);
1157
1259
  }
@@ -1204,10 +1306,51 @@ class Hub extends EventEmitter<{ event: [EventEnvelope]; task: [TaskTurnEnd] }>
1204
1306
  // 和 inflight 一起清零:被打断的那一轮不一定还会报结果,留着的话下一轮的结果会被当成定时任务的
1205
1307
  for (const tag of this.turnTags.get(key) ?? []) if (tag) this.emit("task", { ...tag, key, status: "interrupted", text: "被停止了" });
1206
1308
  this.turnTags.delete(key);
1309
+ this.clearFifo(key);
1207
1310
  this.setInflight(key, 0);
1208
1311
  return "已请求中断。";
1209
1312
  }
1210
1313
 
1314
+ /**
1315
+ * 打断并发送:停掉正在跑的这一轮,排着队的 id 这条挪到最前面,马上处理它;其余排着的照常接在后面。
1316
+ * 和停止不同,不丢排队的消息、也不清计数 —— 被打断的这一轮会正常报结果(已中断),之后就轮到它
1317
+ */
1318
+ async jump(key: string, id: string): Promise<string> {
1319
+ const fifo = this.turnFifo.get(key) ?? [];
1320
+ const idx = fifo.findIndex((e) => e.id === id);
1321
+ if (idx === 0) return "这条已经在处理了。";
1322
+ const live = this.live.get(key);
1323
+ if (idx < 0 || !live) return "这条消息已经不在排队了。";
1324
+ const [entry] = fifo.splice(idx, 1);
1325
+ fifo.splice(1, 0, entry!);
1326
+ // turnTags 和 fifo 一一对应,一起挪
1327
+ const tags = this.turnTags.get(key);
1328
+ if (tags && tags.length === fifo.length) tags.splice(1, 0, ...tags.splice(idx, 1));
1329
+ this.stoppedAt.set(key, Date.now());
1330
+ interactions.cancelAll(key, "本轮已中断");
1331
+ await live.session.interrupt({ keepQueue: true, first: entry!.input });
1332
+ return "已打断,马上处理这条。";
1333
+ }
1334
+
1335
+ /** 一轮出结果了:队首出队,下一条要是排过队的,告诉页面轮到它了 */
1336
+ private finishTurn(key: string): void {
1337
+ const fifo = this.turnFifo.get(key);
1338
+ if (!fifo) return;
1339
+ fifo.shift();
1340
+ const next = fifo[0];
1341
+ if (next?.queued) {
1342
+ next.queued = false;
1343
+ this.emitEvent(key, { type: "queue", id: next.id, state: "started" });
1344
+ }
1345
+ if (fifo.length === 0) this.turnFifo.delete(key);
1346
+ }
1347
+
1348
+ /** 停止 / 会话丢了:在跑的那条不管(它的结果或中断自己会报),排着队的都没发出 */
1349
+ private clearFifo(key: string): void {
1350
+ for (const e of (this.turnFifo.get(key) ?? []).slice(1)) this.emitEvent(key, { type: "queue", id: e.id, state: "dropped" });
1351
+ this.turnFifo.delete(key);
1352
+ }
1353
+
1211
1354
  private async cdNow(key: string, dir: string, op: SwitchOp): Promise<string> {
1212
1355
  const resolved = resolveWorkspacePath(dir);
1213
1356
  if (!resolved) return `不允许:目录必须存在,且在 ${config().workspaceRoot} 之内(不能经符号链接跳出去)。`;
@@ -1371,12 +1514,16 @@ class Hub extends EventEmitter<{ event: [EventEnvelope]; task: [TaskTurnEnd] }>
1371
1514
  switch (cmd.kind) {
1372
1515
  case "help":
1373
1516
  return text(helpText(ctx.allowedAgents, channel));
1517
+ case "skills":
1518
+ return text(skillsText(memoryOwnerOf(getConversation(key)!)));
1374
1519
  case "commands": {
1375
1520
  const conv = getConversation(key)!;
1521
+ const owner = memoryOwnerOf(conv);
1376
1522
  const all = await this.commandCatalog(conv.agent, {
1377
1523
  cwd: conv.cwd,
1378
1524
  allowedAgents: ctx.allowedAgents,
1379
1525
  ...(channel ? { channel } : {}),
1526
+ ...(owner && getUser(owner)?.role !== "admin" ? { member: true } : {}),
1380
1527
  });
1381
1528
  return { type: "commands", agent: conv.agent, commands: all.filter((c) => !c.hidden) };
1382
1529
  }
@@ -1449,7 +1596,7 @@ class Hub extends EventEmitter<{ event: [EventEnvelope]; task: [TaskTurnEnd] }>
1449
1596
  */
1450
1597
  async commandCatalog(
1451
1598
  agent: string,
1452
- opts: { cwd?: string; allowedAgents: string[]; channel?: string },
1599
+ opts: { cwd?: string; allowedAgents: string[]; channel?: string; member?: boolean },
1453
1600
  ): Promise<CommandEntry[]> {
1454
1601
  const driver = this.enabledAgents().find((d) => d.id === agent);
1455
1602
  let native: AgentCommand[] = [];
@@ -1459,6 +1606,8 @@ class Hub extends EventEmitter<{ event: [EventEnvelope]; task: [TaskTurnEnd] }>
1459
1606
  return [];
1460
1607
  });
1461
1608
  }
1609
+ // 成员:机器上装的技能不列(用了也会被 blockedSlash 拦下)
1610
+ if (opts.member) native = native.filter((c) => !isMachineCommand(c.name, native, opts.cwd ?? config().workspaceRoot));
1462
1611
  const prefs = (config().commands as Record<string, CommandPrefs | undefined>)[agent];
1463
1612
  return buildCatalog({ allowedAgents: opts.allowedAgents, native, prefs, ...(opts.channel ? { channel: opts.channel } : {}) });
1464
1613
  }
@@ -1466,6 +1615,7 @@ class Hub extends EventEmitter<{ event: [EventEnvelope]; task: [TaskTurnEnd] }>
1466
1615
  async shutdown(): Promise<void> {
1467
1616
  for (const key of [...this.live.keys()]) await this.drop(key, "服务正在重启");
1468
1617
  for (const driver of this.drivers.values()) await driver.stop().catch(() => {});
1618
+ flushUsage();
1469
1619
  }
1470
1620
  }
1471
1621
 
@@ -3,6 +3,7 @@ import { deleteMemory, memoryPrompt, readMemory } from "./memory.js";
3
3
  import { purgeHistory } from "./chat-history.js";
4
4
  import { unlinkUser } from "./feishu-links.js";
5
5
  import { deleteUserTasks } from "./tasks.js";
6
+ import { purgeUserSkills } from "./skills.js";
6
7
  import type { Conversation } from "./store.js";
7
8
  import { getUser, legacyOwnerId } from "./users.js";
8
9
 
@@ -37,10 +38,11 @@ export function sessionMemory(
37
38
  };
38
39
  }
39
40
 
40
- /** 删用户时一起删掉的个人数据:记忆、聊天记录索引、飞书绑定和定时任务 */
41
+ /** 删用户时一起删掉的个人数据:记忆、聊天记录索引、飞书绑定、定时任务和技能(他建的技能,装了的人也就没了) */
41
42
  export function purgeUserData(userId: string): void {
42
43
  deleteMemory(userId);
43
44
  purgeHistory(userId);
44
45
  unlinkUser(userId);
45
46
  deleteUserTasks(userId);
47
+ purgeUserSkills(userId);
46
48
  }