agent-loft 0.2.21 → 0.2.23

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.
@@ -8,6 +8,7 @@
8
8
  import crypto from "node:crypto";
9
9
  import path from "node:path";
10
10
  import { dataDir, readJson, writeJsonAtomic } from "../../home.js";
11
+ import { legacyOwnerId } from "../../core/users.js";
11
12
 
12
13
  export type PushSubscriptionRecord = {
13
14
  endpoint: string;
@@ -16,8 +17,13 @@ export type PushSubscriptionRecord = {
16
17
  label: string;
17
18
  createdAt: number;
18
19
  lastOkAt?: number;
20
+ /** 哪个用户开的(core/users.ts)。升级前的没有:第一次读到时认给迁移出来的那个管理员并记下 */
21
+ userId?: string;
19
22
  };
20
23
 
24
+ /** 按订阅的主人筛选:拿到的是订阅里记的 userId(可能没有) */
25
+ export type OwnerFilter = (userId: string | undefined) => boolean;
26
+
21
27
  export type PushPayload = {
22
28
  title: string;
23
29
  body: string;
@@ -42,6 +48,12 @@ function load(): Store {
42
48
  if (!store) {
43
49
  store = readJson<Store>(FILE(), { subscriptions: [] });
44
50
  if (!Array.isArray(store.subscriptions)) store.subscriptions = [];
51
+ // 升级前的订阅:记到迁移出来的管理员名下。只做一次 —— 之后那个管理员被停用、删掉,
52
+ // 订阅也不会转给别人(那样别人的通知会推到他的手机上)
53
+ const owner = legacyOwnerId();
54
+ const legacy = owner ? store.subscriptions.filter((x) => !x.userId) : [];
55
+ for (const x of legacy) x.userId = owner;
56
+ if (legacy.length > 0) writeJsonAtomic(FILE(), store);
45
57
  }
46
58
  return store;
47
59
  }
@@ -134,7 +146,8 @@ function describeUa(ua: string): string {
134
146
  return browser ? `${device} · ${browser}` : device;
135
147
  }
136
148
 
137
- export function addSubscription(sub: unknown, userAgent: string): PushSubscriptionRecord {
149
+ /** 同一个浏览器换了个账号登录再开通知:订阅归新账号 */
150
+ export function addSubscription(sub: unknown, userAgent: string, userId?: string): PushSubscriptionRecord {
138
151
  if (!validSubscription(sub)) throw new Error("订阅信息不完整");
139
152
  const s = load();
140
153
  const record: PushSubscriptionRecord = {
@@ -142,24 +155,25 @@ export function addSubscription(sub: unknown, userAgent: string): PushSubscripti
142
155
  keys: { p256dh: sub.keys.p256dh, auth: sub.keys.auth },
143
156
  label: describeUa(userAgent),
144
157
  createdAt: Date.now(),
158
+ ...(userId ? { userId } : {}),
145
159
  };
146
160
  s.subscriptions = [...s.subscriptions.filter((x) => x.endpoint !== sub.endpoint), record];
147
161
  persist();
148
162
  return record;
149
163
  }
150
164
 
151
- export function removeSubscription(endpoint: string): boolean {
165
+ export function removeSubscription(endpoint: string, mine?: OwnerFilter): boolean {
152
166
  const s = load();
153
167
  const before = s.subscriptions.length;
154
- s.subscriptions = s.subscriptions.filter((x) => x.endpoint !== endpoint);
168
+ s.subscriptions = s.subscriptions.filter((x) => x.endpoint !== endpoint || (mine && !mine(x.userId)));
155
169
  if (s.subscriptions.length === before) return false;
156
170
  persist();
157
171
  return true;
158
172
  }
159
173
 
160
174
  /** 给设置页看的:不带密钥 */
161
- export function listSubscriptions(): { endpoint: string; label: string; createdAt: number; lastOkAt?: number }[] {
162
- return load().subscriptions.map(({ endpoint, label, createdAt, lastOkAt }) => ({
175
+ export function listSubscriptions(mine?: OwnerFilter): { endpoint: string; label: string; createdAt: number; lastOkAt?: number }[] {
176
+ return load().subscriptions.filter((x) => !mine || mine(x.userId)).map(({ endpoint, label, createdAt, lastOkAt }) => ({
163
177
  endpoint,
164
178
  label,
165
179
  createdAt,
@@ -201,9 +215,17 @@ async function sendOne(sub: PushSubscriptionRecord, payload: PushPayload): Promi
201
215
  return true;
202
216
  }
203
217
 
204
- /** 推给所有订阅的设备,返回成功的数量。单个设备失败不影响别的 */
205
- export async function pushToAll(payload: PushPayload): Promise<number> {
206
- const subs = [...load().subscriptions];
218
+ /** 某个用户的订阅全部删掉(删用户时) */
219
+ export function removeSubscriptionsOf(mine: OwnerFilter): void {
220
+ const s = load();
221
+ const before = s.subscriptions.length;
222
+ s.subscriptions = s.subscriptions.filter((x) => !mine(x.userId));
223
+ if (s.subscriptions.length !== before) persist();
224
+ }
225
+
226
+ /** 推给订阅的设备(给了 to 就只推给它挑出来的主人),返回成功的数量。单个设备失败不影响别的 */
227
+ export async function pushToAll(payload: PushPayload, to?: OwnerFilter): Promise<number> {
228
+ const subs = load().subscriptions.filter((x) => !to || to(x.userId));
207
229
  if (subs.length === 0) return 0;
208
230
  const results = await Promise.all(
209
231
  subs.map((sub) =>
@@ -10,13 +10,15 @@ import { fileURLToPath } from "node:url";
10
10
  import { WebSocketServer, type WebSocket } from "ws";
11
11
  import { config, onConfigChange } from "../../core/config.js";
12
12
  import { parseCommand } from "../../core/commands.js";
13
- import { hub, type EventEnvelope } from "../../core/hub.js";
13
+ 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 { RUNNING_VERSION } from "../../core/upgrade.js";
17
17
  import type { Settlement } from "../../core/types.js";
18
- import { authed, handleApi, takeUploads } from "./api.js";
19
- import { authEvents, ensureSetupToken, validateSession } from "./auth.js";
18
+ import { getUser, isAdmin, legacyOwnerId, type User } from "../../core/users.js";
19
+ import { currentUser, handleApi, takeUploads } from "./api.js";
20
+ import { authEvents, ensureSetupToken, sessionUser } from "./auth.js";
21
+ import { canSeeKey, sessionVisibleTo } from "./access.js";
20
22
  import { cookies } from "./http.js";
21
23
  import { COOKIE } from "./api.js";
22
24
  import { HttpError, sameOrigin, SECURITY_HEADERS, sendJson } from "./http.js";
@@ -134,6 +136,8 @@ type Client = {
134
136
  subs: Set<string>;
135
137
  alive: boolean;
136
138
  token: string;
139
+ /** 登录的用户。每次复查登录态时刷新(角色可能被改了) */
140
+ user: User;
137
141
  /** 页面在前台,且正看着哪条会话(前端在切会话、切前后台时上报) */
138
142
  visible: boolean;
139
143
  viewing: string | null;
@@ -142,12 +146,18 @@ type Client = {
142
146
  /** 每次进程启动换一个:客户端发现变了,就知道 seq 已经从头计数,得整页重拉 */
143
147
  const EPOCH = Math.random().toString(36).slice(2, 10);
144
148
 
145
- /** 登录态已失效的连接:断开。客户端收到 4401 会回到登录页 */
146
- function checkClient(client: Client): boolean {
147
- if (validateSession(client.token)) return true;
149
+ /** 登录态已失效(退出、被踢、用户被停用 / 删除)的连接:断开。客户端收到 4401 会回到登录页 */
150
+ function checkClient(client: Client): User | undefined {
151
+ const user = sessionUser(client.token);
152
+ if (user) {
153
+ client.user = user;
154
+ // 角色被改了(管理员降成成员):已经订阅着的、现在看不到的会话,事件不能再推给他
155
+ for (const key of client.subs) if (!canSeeKey(user, key)) client.subs.delete(key);
156
+ return user;
157
+ }
148
158
  client.ws.close(4401, "登录已失效");
149
159
  clients.delete(client);
150
- return false;
160
+ return undefined;
151
161
  }
152
162
  const clients = new Set<Client>();
153
163
 
@@ -160,13 +170,14 @@ function broadcastEvent(env: EventEnvelope): void {
160
170
  const listChanged = ["user", "meta", "state", "result", "error"].includes(env.ev.type);
161
171
  const info = listChanged ? hub.info(env.key) : undefined;
162
172
  for (const client of [...clients]) {
173
+ // 订阅时已经核对过是不是他的会话
163
174
  if (client.subs.has(env.key)) send(client, { t: "ev", ...env });
164
- if (info) send(client, { t: "conv", info });
175
+ if (info && canSeeKey(client.user, env.key)) send(client, { t: "conv", info });
165
176
  }
166
177
  }
167
178
 
168
179
  function broadcastInteraction(i: Interaction): void {
169
- for (const client of clients) send(client, { t: "ix", interaction: i });
180
+ for (const client of clients) if (canSeeKey(client.user, i.convKey)) send(client, { t: "ix", interaction: i });
170
181
  }
171
182
 
172
183
  // ---------- 系统通知(Web Push) ----------
@@ -210,8 +221,14 @@ type Notice = { title: string; body: string; tag: string };
210
221
  */
211
222
  function notify(key: string, notice: Notice): void {
212
223
  if (!isWebConv(key)) return;
213
- for (const client of clients) send(client, { t: "notice", key, ...notice });
214
- if (hasSubscriptions() && !watching(key)) void pushToAll({ ...notice, url: `/#${encodeURIComponent(key)}` });
224
+ for (const client of clients) if (canSeeKey(client.user, key)) send(client, { t: "notice", key, ...notice });
225
+ if (!hasSubscriptions() || watching(key)) return;
226
+ // 只推给这条会话的主人(没记主人的订阅算迁移出来的那个管理员的)
227
+ const legacy = legacyOwnerId();
228
+ void pushToAll({ ...notice, url: `/#${encodeURIComponent(key)}` }, (owner) => {
229
+ const u = getUser(owner ?? legacy);
230
+ return !!u && !u.disabled && canSeeKey(u, key);
231
+ });
215
232
  }
216
233
 
217
234
  function pushEvent(env: EventEnvelope): void {
@@ -240,7 +257,8 @@ const BY = "已在 Web 处理";
240
257
 
241
258
  function handleClientMessage(client: Client, raw: string): void {
242
259
  // 每条消息都复查登录态:握手时有效不代表现在还有效(改了密码、被踢了)
243
- if (!checkClient(client)) return;
260
+ const user = checkClient(client);
261
+ if (!user) return;
244
262
  let msg: Record<string, unknown>;
245
263
  try {
246
264
  const parsed: unknown = JSON.parse(raw);
@@ -260,7 +278,7 @@ function handleClientMessage(client: Client, raw: string): void {
260
278
  return;
261
279
 
262
280
  case "sub": {
263
- if (!getConversation(key)) return ack(false, { error: "会话不存在" });
281
+ if (!getConversation(key) || !canSeeKey(user, key)) return ack(false, { error: "会话不存在" });
264
282
  client.subs.add(key);
265
283
  const since = typeof msg.since === "number" ? msg.since : 0;
266
284
  // 客户端的 seq 来自上一个进程(比现在的还大),或者换了实例:补不了,整页重拉
@@ -291,7 +309,7 @@ function handleClientMessage(client: Client, raw: string): void {
291
309
  return ack(true);
292
310
 
293
311
  case "send": {
294
- if (!getConversation(key)) return ack(false, { error: "会话不存在" });
312
+ if (!getConversation(key) || !canSeeKey(user, key)) return ack(false, { error: "会话不存在" });
295
313
  const text = typeof msg.text === "string" ? msg.text : "";
296
314
  const attachments = takeUploads(key, msg.uploads);
297
315
  if (!text.trim() && attachments.length === 0) return ack(false, { error: "空消息" });
@@ -301,7 +319,16 @@ function handleClientMessage(client: Client, raw: string): void {
301
319
  if (command && command.kind !== "chat") {
302
320
  const allowedAgents = hub.enabledAgents().map((d) => d.id);
303
321
  void hub
304
- .command(key, command, { allowedAgents, by: "Web 命令" })
322
+ .command(key, command, {
323
+ allowedAgents,
324
+ by: "Web 命令",
325
+ ...(() => {
326
+ const sessionVisible = sessionVisibleTo(user);
327
+ return sessionVisible ? { sessionVisible } : {};
328
+ })(),
329
+ convVisible: (k) => canSeeKey(user, k),
330
+ allowGlobalAlways: isAdmin(user),
331
+ })
305
332
  .then((reply) => ack(true, { reply, info: hub.info(key) }))
306
333
  .catch((err) => ack(false, { error: err instanceof Error ? err.message : String(err) }));
307
334
  return;
@@ -312,10 +339,15 @@ function handleClientMessage(client: Client, raw: string): void {
312
339
 
313
340
  case "ix": {
314
341
  const id = String(msg.id ?? "");
342
+ const target = interactions.get(id);
343
+ if (!target || !canSeeKey(user, target.convKey)) return ack(false, { error: "请求已失效" });
315
344
  let ok = false;
316
345
  let error: string | undefined;
317
346
  switch (msg.op) {
318
347
  case "decide":
348
+ if (msg.decision === "allow-always" && target.spec.kind === "approval" && target.spec.alwaysGlobal && !isAdmin(user)) {
349
+ return ack(false, { error: GLOBAL_ALWAYS_DENIED });
350
+ }
319
351
  ok = !!interactions.decide(id, String(msg.decision) as Settlement, BY);
320
352
  break;
321
353
  case "select":
@@ -392,14 +424,15 @@ export async function startWeb(): Promise<void> {
392
424
  server.on("upgrade", (req, socket, head) => {
393
425
  try {
394
426
  const url = new URL(req.url ?? "/", "http://local");
395
- if (url.pathname !== "/ws" || !sameOrigin(req) || !authed(req)) {
427
+ const user = url.pathname === "/ws" && sameOrigin(req) ? currentUser(req) : undefined;
428
+ if (!user) {
396
429
  socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
397
430
  socket.destroy();
398
431
  return;
399
432
  }
400
433
  const token = cookies(req)[COOKIE] ?? "";
401
434
  wss!.handleUpgrade(req, socket, head, (ws) => {
402
- const client: Client = { ws, subs: new Set(), alive: true, token, visible: false, viewing: null };
435
+ const client: Client = { ws, subs: new Set(), alive: true, token, user, visible: false, viewing: null };
403
436
  clients.add(client);
404
437
  ws.on("pong", () => (client.alive = true));
405
438
  ws.on("message", (data) => {
package/src/cli/doctor.ts CHANGED
@@ -3,6 +3,7 @@ import { spawnSync } from "node:child_process";
3
3
  import { config, loadConfig } from "../core/config.js";
4
4
  import { FeishuClient } from "../channels/feishu/client.js";
5
5
  import { configPath } from "../home.js";
6
+ import { hasUsers, listUsers } from "../core/users.js";
6
7
 
7
8
  loadConfig();
8
9
  const cfg = config();
@@ -36,4 +37,4 @@ for (const bot of cfg.feishu.bots) {
36
37
  }
37
38
 
38
39
  console.log("\nWeb");
39
- console.log(` ${cfg.web.enabled ? `http://${cfg.web.host}:${cfg.web.port}` : "未启用"}${cfg.web.passwordHash ? "" : "(还没设密码,首次打开需要启动日志里的设置码)"}`);
40
+ console.log(` ${cfg.web.enabled ? `http://${cfg.web.host}:${cfg.web.port}` : "未启用"}${hasUsers() ? `(${listUsers().length} 个用户)` : "(还没设密码,首次打开需要启动日志里的设置码)"}`);
package/src/cli/passwd.ts CHANGED
@@ -1,15 +1,32 @@
1
- // agent-loft passwd:在终端里重置 Web 登录密码(忘记密码时用)。所有已登录设备会下线。
1
+ // agent-loft passwd [新密码] [--user 用户名]:在终端里重置 Web 登录密码(忘记密码时用)。
2
+ // 不指定用户就是最早的那个管理员;还一个用户都没有时,建一个叫 admin(或 --user 给的名字)的管理员。
3
+ // 被重置的用户所有已登录设备会下线。
2
4
  import readline from "node:readline/promises";
3
5
  import { loadConfig } from "../core/config.js";
4
- import { setPassword } from "../channels/web/auth.js";
6
+ import { findUserByName, getUser, hasUsers, legacyOwnerId } from "../core/users.js";
7
+ import { setUserPassword, setupFirstAdmin } from "../channels/web/auth.js";
5
8
 
6
9
  loadConfig();
7
- const fromArg = process.argv[2];
8
- let password = fromArg;
10
+ const args = process.argv.slice(2);
11
+ const at = args.indexOf("--user");
12
+ const name = at >= 0 ? args[at + 1] : undefined;
13
+ if (at >= 0 && !name) throw new Error("--user 后面要跟用户名");
14
+ const positional = args.filter((_, i) => at < 0 || (i !== at && i !== at + 1));
15
+
16
+ const target = !hasUsers() ? undefined : name ? findUserByName(name) : getUser(legacyOwnerId());
17
+ if (hasUsers() && !target) throw new Error(name ? `没有用户「${name}」。用 agent-loft user list 看看有哪些` : "没有能登录的管理员,用 --user 指定用户");
18
+
19
+ let password = positional[0];
9
20
  if (!password) {
10
21
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
11
- password = await rl.question("新的 Web 登录密码(至少 8 位):");
22
+ password = await rl.question(`${target ? `「${target.name}」` : "管理员"}的新 Web 登录密码(至少 8 位):`);
12
23
  rl.close();
13
24
  }
14
- setPassword(password.trim());
15
- console.log("密码已更新,所有已登录的设备需要重新登录。");
25
+
26
+ if (target) {
27
+ setUserPassword(target.id, password.trim());
28
+ console.log(`「${target.name}」的密码已更新,他已登录的设备需要重新登录。`);
29
+ } else {
30
+ const user = setupFirstAdmin(name ?? "admin", password.trim());
31
+ console.log(`已创建管理员「${user.name}」,用这个用户名和密码登录。`);
32
+ }
@@ -0,0 +1,99 @@
1
+ // agent-loft user …:在终端里管理 Web 登录用户(Web 上 设置 → 用户 里也能做)。
2
+ //
3
+ // list 列出用户
4
+ // add <用户名> [--admin] [密码] 新建用户(默认普通成员)
5
+ // passwd <用户名> [新密码] 重置密码,他的设备全部下线
6
+ // role <用户名> admin|member 改角色
7
+ // disable <用户名> / enable <用户名>
8
+ // remove <用户名>
9
+ import readline from "node:readline/promises";
10
+ import { loadConfig } from "../core/config.js";
11
+ import { createUser, findUserByName, legacyOwnerId, listUsers, removeUser } from "../core/users.js";
12
+ import { patchUser, revokeUserSessions } from "../channels/web/auth.js";
13
+ import { removeSubscriptionsOf } from "../channels/web/push.js";
14
+
15
+ loadConfig();
16
+
17
+ const USAGE = `用法:
18
+ agent-loft user list
19
+ agent-loft user add <用户名> [--admin] [密码]
20
+ agent-loft user passwd <用户名> [新密码]
21
+ agent-loft user role <用户名> admin|member
22
+ agent-loft user disable <用户名>
23
+ agent-loft user enable <用户名>
24
+ agent-loft user remove <用户名>
25
+
26
+ 注意:多用户是「信任型」的 —— 所有 agent 都以同一个系统用户运行,能读到这台机器上的任何东西。
27
+ 只把账号给你信任的人。`;
28
+
29
+ const [sub, ...rest] = process.argv.slice(2);
30
+ const flags = new Set(rest.filter((a) => a.startsWith("--")));
31
+ const args = rest.filter((a) => !a.startsWith("--"));
32
+
33
+ async function ask(prompt: string): Promise<string> {
34
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
35
+ const answer = await rl.question(prompt);
36
+ rl.close();
37
+ return answer.trim();
38
+ }
39
+
40
+ function need(name: string | undefined) {
41
+ if (!name) throw new Error(USAGE);
42
+ const user = findUserByName(name);
43
+ if (!user) throw new Error(`没有用户「${name}」`);
44
+ return user;
45
+ }
46
+
47
+ switch (sub) {
48
+ case undefined:
49
+ case "list": {
50
+ const users = listUsers();
51
+ if (users.length === 0) console.log("还没有用户。打开网页用启动日志里的设置码建第一个管理员,或者运行 agent-loft passwd。");
52
+ for (const u of users) {
53
+ console.log(`${u.name}\t${u.role === "admin" ? "管理员" : "成员"}${u.disabled ? "\t已停用" : ""}`);
54
+ }
55
+ break;
56
+ }
57
+ case "add": {
58
+ const name = args[0];
59
+ if (!name) throw new Error(USAGE);
60
+ const password = args[1] ?? (await ask(`「${name}」的登录密码(至少 8 位):`));
61
+ const user = createUser({ name, password, role: flags.has("--admin") ? "admin" : "member" });
62
+ console.log(`已创建${user.role === "admin" ? "管理员" : "成员"}「${user.name}」。`);
63
+ break;
64
+ }
65
+ case "passwd": {
66
+ const user = need(args[0]);
67
+ const password = args[1] ?? (await ask(`「${user.name}」的新密码(至少 8 位):`));
68
+ patchUser(user.id, { password });
69
+ console.log(`「${user.name}」的密码已更新,他已登录的设备需要重新登录。`);
70
+ break;
71
+ }
72
+ case "role": {
73
+ const user = need(args[0]);
74
+ const role = args[1];
75
+ if (role !== "admin" && role !== "member") throw new Error(USAGE);
76
+ patchUser(user.id, { role });
77
+ console.log(`「${user.name}」现在是${role === "admin" ? "管理员" : "成员"}。`);
78
+ break;
79
+ }
80
+ case "disable":
81
+ case "enable": {
82
+ const user = need(args[0]);
83
+ patchUser(user.id, { disabled: sub === "disable" });
84
+ console.log(`「${user.name}」已${sub === "disable" ? "停用,设备已全部下线" : "启用"}。`);
85
+ break;
86
+ }
87
+ case "remove": {
88
+ const user = need(args[0]);
89
+ // 升级前没记主人的推送订阅算在最早的管理员名下:删人之前先记下是谁
90
+ const legacy = legacyOwnerId();
91
+ removeUser(user.id);
92
+ removeSubscriptionsOf((owner) => (owner ?? legacy) === user.id);
93
+ revokeUserSessions(user.id);
94
+ console.log(`已删除「${user.name}」。他的会话还在,归管理员。`);
95
+ break;
96
+ }
97
+ default:
98
+ throw new Error(USAGE);
99
+ }
@@ -16,25 +16,26 @@ export type BuiltinSpec = {
16
16
  feishuOnly?: boolean;
17
17
  };
18
18
 
19
+ // 按常用程度排:这也是设置 → 命令、「/」列表和 /help 里的默认顺序
19
20
  export const BUILTIN_COMMANDS: BuiltinSpec[] = [
20
21
  { name: "new", args: "[模型]", description: "开一条新会话" },
21
- { name: "status", description: "查看当前会话、模型、目录、剩余额度" },
22
22
  { name: "stop", description: "中断当前执行" },
23
23
  { name: "model", args: "[名称]", description: "切换模型(不带参数弹选择)" },
24
24
  { name: "effort", args: "[程度]", description: "切换思考程度" },
25
+ { name: "status", description: "查看当前会话、模型、目录、剩余额度" },
25
26
  { name: "sessions", args: "[all]", description: "列出历史会话,点按钮恢复;加 all 列所有目录" },
26
27
  { name: "resume", args: "<会话ID>", description: "恢复指定会话" },
28
+ { name: "cd", args: "<相对目录>", description: "切换工作目录(限 workspace 内)" },
29
+ { name: "pwd", description: "显示当前工作目录" },
27
30
  { name: "fork", description: "从当前会话分叉,原会话保留" },
31
+ { name: "autoedit", args: "[on|off]", description: "本对话自动允许改工作目录内的文件(Bash 仍询问)" },
28
32
  { name: "agent", args: "[名称]", description: "查看 / 切换 agent", multiAgentOnly: true },
29
33
  { name: "claude", description: "切换到 Claude Code", multiAgentOnly: true },
30
34
  { name: "codex", description: "切换到 Codex", multiAgentOnly: true },
31
- { name: "cd", args: "<相对目录>", description: "切换工作目录(限 workspace 内)" },
32
- { name: "pwd", description: "显示当前工作目录" },
33
- { name: "autoedit", args: "[on|off]", description: "本对话自动允许改工作目录内的文件(Bash 仍询问)" },
34
35
  { name: "web", description: "接上一条 Web 会话,在飞书里查看和回复", feishuOnly: true },
35
36
  { name: "detach", description: "不再接着 Web 会话,回到这个聊天自己的会话", feishuOnly: true },
36
- { name: "approve", args: "<请求ID> allow-once|allow-always|allow-session|deny", description: "审批兜底(卡片失效时用)" },
37
37
  { name: "help", description: "显示帮助" },
38
+ { name: "approve", args: "<请求ID> allow-once|allow-always|allow-session|deny", description: "审批兜底(卡片失效时用)" },
38
39
  ];
39
40
 
40
41
  /** agent 自己报上来的命令 */
@@ -132,7 +133,10 @@ function needsArgs(args: string | undefined): boolean {
132
133
  * 合并内置命令和 agent 原生命令,再按用户配置排序。
133
134
  * - 只对接得上的 agent 才列 /claude /codex 这类快捷切换;
134
135
  * - 和内置命令重名的原生命令丢掉:反正会被 agent-loft 先拦下来,列出来只会误导;
135
- * - 配置里排过序的在前,按配置的顺序;没排过的(比如新装的 skill)按默认顺序接在后面。
136
+ * - 配置里排过序的在前,按配置的顺序;没排过的(比如新装的 skill)按默认顺序接在后面;
137
+ * - 默认顺序:agent-loft 内置命令(按常用程度)→ 自定义命令 → agent 原生命令;
138
+ * - agent 原生命令默认不显示:只有在设置里保存过(出现在 order 里)且没被隐藏的才显示。
139
+ * 设置页每次保存都会把整张表的顺序存下来,所以存过的照用户的选择,以后新装的 skill 默认不勾。
136
140
  */
137
141
  export function buildCatalog(params: {
138
142
  allowedAgents: string[];
@@ -202,7 +206,7 @@ export function buildCatalog(params: {
202
206
  if (rb !== undefined) return 1;
203
207
  return a.i - b.i;
204
208
  })
205
- .map(({ c }) => ({ ...c, hidden: hidden.has(c.name) }));
209
+ .map(({ c }) => ({ ...c, hidden: hidden.has(c.name) || (c.source === "agent" && !rank.has(c.name)) }));
206
210
  }
207
211
 
208
212
  /** 「/name args」,给人看的写法 */
@@ -91,7 +91,7 @@ export function helpText(agents: string[], channel?: string): string {
91
91
  "",
92
92
  "Agent 给选项时会发一张提问卡,点按钮作答;都不合适就直接发消息,那条消息会被当成你的回答。",
93
93
  "",
94
- "**命令**(需整条消息只有命令;只发一个 `/` 可以列出全部命令,包括 agent 自己的)",
94
+ "**命令**(需整条消息只有命令;只发一个 `/` 可以列出命令;agent 自己的命令默认不在列表里,可以在设置 → 命令里勾选,没勾的照样可以直接发)",
95
95
  "",
96
96
  ...BUILTIN_COMMANDS.filter((c) => !c.multiAgentOnly || multi)
97
97
  .filter((c) => !c.feishuOnly || channel === "feishu")
package/src/core/hub.ts CHANGED
@@ -18,6 +18,7 @@ import {
18
18
  getConversation,
19
19
  isExpired,
20
20
  listConversations,
21
+ recordSessionOwner,
21
22
  touchConversation,
22
23
  updateAgentBinding,
23
24
  updateConversation,
@@ -131,6 +132,9 @@ export type ConversationInfo = {
131
132
  canFork?: boolean;
132
133
  };
133
134
 
135
+ /** 普通成员点了会落盘的「总是允许」 */
136
+ export const GLOBAL_ALWAYS_DENIED = "「总是允许」会对所有人的会话生效,只有管理员能点。可以用「允许一次」或「本会话全部允许」。";
137
+
134
138
  const BUFFER_MAX = 400;
135
139
  const CONTEXT_MAX = 500;
136
140
  /** 会占住会话操作队列的 agent 查询,最多等这么久 */
@@ -384,6 +388,8 @@ class Hub extends EventEmitter<{ event: [EventEnvelope] }> {
384
388
  channelId: string;
385
389
  defaultAgent: string;
386
390
  title?: string;
391
+ /** 谁的对话(用户 id)。只有 Web 上新建的有 */
392
+ owner?: string;
387
393
  }): Conversation {
388
394
  return ensureConversation({
389
395
  key: params.key,
@@ -391,6 +397,7 @@ class Hub extends EventEmitter<{ event: [EventEnvelope] }> {
391
397
  channelId: params.channelId,
392
398
  agent: params.defaultAgent,
393
399
  ...(params.title ? { title: params.title } : {}),
400
+ ...(params.owner ? { owner: params.owner } : {}),
394
401
  });
395
402
  }
396
403
 
@@ -429,8 +436,10 @@ class Hub extends EventEmitter<{ event: [EventEnvelope] }> {
429
436
  };
430
437
  }
431
438
 
432
- list(): ConversationInfo[] {
439
+ /** visible:只列这些(按用户过滤,见 core/users.ts 的 ownedBy) */
440
+ list(visible?: (conv: Conversation) => boolean): ConversationInfo[] {
433
441
  return listConversations()
442
+ .filter((c) => !visible || visible(c))
434
443
  .map((c) => this.info(c.key)!)
435
444
  .sort((a, b) => b.activeAt - a.activeAt);
436
445
  }
@@ -546,6 +555,7 @@ class Hub extends EventEmitter<{ event: [EventEnvelope] }> {
546
555
  // 分叉中还报的是原会话 id:分叉尚未生效,什么都不改
547
556
  if (forkFrom && sessionId === forkFrom) return;
548
557
  updateAgentBinding(key, agent, { sessionId, forkFrom: undefined });
558
+ recordSessionOwner(sessionId, current?.owner);
549
559
  this.emitEvent(key, { type: "meta" });
550
560
  },
551
561
  },
@@ -887,13 +897,21 @@ class Hub extends EventEmitter<{ event: [EventEnvelope] }> {
887
897
  return `已恢复会话 ${sessionId},直接接着说就行。${note}`;
888
898
  }
889
899
 
890
- async sessions(key: string, all: boolean): Promise<Extract<CommandReply, { type: "sessions" }>> {
900
+ /** visible:这个用户能看到哪些 agent 会话(多用户时按人过滤)。不给就是全部 */
901
+ async sessions(
902
+ key: string,
903
+ all: boolean,
904
+ visible?: (sessionId: string) => boolean,
905
+ ): Promise<Extract<CommandReply, { type: "sessions" }>> {
891
906
  const conv = getConversation(key)!;
892
907
  const driver = this.requireDriver(conv.agent);
908
+ // 要过滤的话多拿一些,滤完再截到 10 条
909
+ const list = async (cwd: string | undefined) =>
910
+ visible ? (await driver.listSessions(cwd, 100)).filter((s) => visible(s.id)).slice(0, 10) : driver.listSessions(cwd, 10);
893
911
  // 先按当前目录列,一条都没有就自动退回全局 —— 回一句「没有历史」是误导
894
- let sessions = all ? [] : await driver.listSessions(conv.cwd, 10);
912
+ let sessions = all ? [] : await list(conv.cwd);
895
913
  const fellBack = !all && sessions.length === 0;
896
- if (all || fellBack) sessions = await driver.listSessions(undefined, 10);
914
+ if (all || fellBack) sessions = await list(undefined);
897
915
  const current = agentBinding(conv).sessionId;
898
916
  return {
899
917
  type: "sessions",
@@ -1051,7 +1069,8 @@ class Hub extends EventEmitter<{ event: [EventEnvelope] }> {
1051
1069
  * 分叉成一条新的 Web 对话:agent 会话复制一份(给了 anchor 就只到那条回复为止),
1052
1070
  * 原对话完全不动。返回新对话的 key。和 /fork 不同 —— /fork 是在同一条对话里换成分叉出来的会话。
1053
1071
  */
1054
- async forkConversation(key: string, anchor?: string): Promise<string> {
1072
+ /** owner:新对话归谁(分叉的人)。不给就沿用原对话的 */
1073
+ async forkConversation(key: string, anchor?: string, owner?: string): Promise<string> {
1055
1074
  const conv = getConversation(key);
1056
1075
  if (!conv) throw new Error(`会话不存在:${key}`);
1057
1076
  const driver = this.requireDriver(conv.agent);
@@ -1062,8 +1081,10 @@ class Hub extends EventEmitter<{ event: [EventEnvelope] }> {
1062
1081
  if (!source) throw new Error("这条对话还没有可分叉的会话");
1063
1082
  const sessionId = await withTimeout(driver.forkAt(source, conv.cwd, anchor), "分叉会话");
1064
1083
  copyFooters(source, sessionId);
1084
+ owner ??= conv.owner;
1065
1085
  const newKey = `web:${crypto.randomBytes(6).toString("hex")}`;
1066
- ensureConversation({ key: newKey, channel: "web", channelId: "web", agent: conv.agent });
1086
+ ensureConversation({ key: newKey, channel: "web", channelId: "web", agent: conv.agent, ...(owner ? { owner } : {}) });
1087
+ recordSessionOwner(sessionId, owner);
1067
1088
  updateConversation(newKey, {
1068
1089
  title: `${conv.title ?? "会话"} · 分叉`.slice(0, 80),
1069
1090
  cwd: conv.cwd,
@@ -1119,7 +1140,17 @@ class Hub extends EventEmitter<{ event: [EventEnvelope] }> {
1119
1140
  async command(
1120
1141
  key: string,
1121
1142
  cmd: ParsedCommand,
1122
- ctx: { allowedAgents: string[]; by: string; channel?: string },
1143
+ ctx: {
1144
+ allowedAgents: string[];
1145
+ by: string;
1146
+ channel?: string;
1147
+ /** 多用户:发命令的人能看到哪些 agent 会话(/sessions 列哪些、/resume 能恢复哪些) */
1148
+ sessionVisible?: (sessionId: string) => boolean;
1149
+ /** 多用户:/approve 只能处理这些会话里的请求 */
1150
+ convVisible?: (convKey: string) => boolean;
1151
+ /** 多用户:false = 不能点会落盘的「总是允许」(见 InteractionSpec.alwaysGlobal) */
1152
+ allowGlobalAlways?: boolean;
1153
+ },
1123
1154
  ): Promise<CommandReply> {
1124
1155
  const text = (t: string): CommandReply => ({ type: "text", text: t });
1125
1156
  const channel = ctx.channel ?? getConversation(key)?.channel;
@@ -1148,10 +1179,13 @@ class Hub extends EventEmitter<{ event: [EventEnvelope] }> {
1148
1179
  case "fork":
1149
1180
  return text(await this.fork(key));
1150
1181
  case "sessions": {
1151
- const reply = await this.sessions(key, cmd.all);
1182
+ const reply = await this.sessions(key, cmd.all, ctx.sessionVisible);
1152
1183
  return reply.sessions.length === 0 ? text("还没有任何历史会话。") : reply;
1153
1184
  }
1154
1185
  case "resume":
1186
+ if (ctx.sessionVisible && !ctx.sessionVisible(cmd.sessionId)) {
1187
+ return text(`找不到会话 ${cmd.sessionId}。\n发 /sessions 看看有哪些可恢复的。`);
1188
+ }
1155
1189
  return text(await this.resume(key, cmd.sessionId));
1156
1190
  case "model":
1157
1191
  return cmd.model ? text(await this.setModel(key, cmd.model)) : await this.models(key);
@@ -1173,6 +1207,13 @@ class Hub extends EventEmitter<{ event: [EventEnvelope] }> {
1173
1207
  case "stop":
1174
1208
  return text(await this.stop(key));
1175
1209
  case "approve": {
1210
+ const target = interactions.get(cmd.requestId);
1211
+ if (target && ctx.convVisible && !ctx.convVisible(target.convKey)) {
1212
+ return text(`请求 ${cmd.requestId} 不存在或已处理。`);
1213
+ }
1214
+ if (target?.spec.kind === "approval" && target.spec.alwaysGlobal && cmd.decision === "allow-always" && ctx.allowGlobalAlways === false) {
1215
+ return text(GLOBAL_ALWAYS_DENIED);
1216
+ }
1176
1217
  const settled = interactions.decide(cmd.requestId, cmd.decision as ApprovalDecision, ctx.by);
1177
1218
  if (!settled && interactions.isPending(cmd.requestId)) {
1178
1219
  return text(`请求 ${cmd.requestId} 不支持 ${cmd.decision}。`);