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.
- package/README.md +24 -13
- package/bin/cli.js +4 -2
- package/package.json +1 -1
- package/src/agents/claude/session.ts +1 -1
- package/src/agents/codex/session.ts +1 -0
- package/src/channels/feishu/bot.ts +22 -8
- package/src/channels/web/access.ts +26 -0
- package/src/channels/web/api.ts +176 -42
- package/src/channels/web/auth.ts +92 -38
- package/src/channels/web/push.ts +30 -8
- package/src/channels/web/server.ts +50 -17
- package/src/cli/doctor.ts +2 -1
- package/src/cli/passwd.ts +24 -7
- package/src/cli/user.ts +99 -0
- package/src/core/command-catalog.ts +11 -7
- package/src/core/commands.ts +1 -1
- package/src/core/hub.ts +49 -8
- package/src/core/store.ts +33 -0
- package/src/core/types.ts +5 -0
- package/src/core/uploads.ts +12 -0
- package/src/core/users.ts +261 -0
- package/web/app.js +238 -43
package/src/channels/web/push.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
206
|
-
const
|
|
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 {
|
|
19
|
-
import {
|
|
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
|
-
/**
|
|
146
|
-
function checkClient(client: Client):
|
|
147
|
-
|
|
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
|
|
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()
|
|
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
|
-
|
|
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, {
|
|
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
|
-
|
|
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}` : "未启用"}${
|
|
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 {
|
|
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
|
|
8
|
-
|
|
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("
|
|
22
|
+
password = await rl.question(`${target ? `「${target.name}」` : "管理员"}的新 Web 登录密码(至少 8 位):`);
|
|
12
23
|
rl.close();
|
|
13
24
|
}
|
|
14
|
-
|
|
15
|
-
|
|
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
|
+
}
|
package/src/cli/user.ts
ADDED
|
@@ -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」,给人看的写法 */
|
package/src/core/commands.ts
CHANGED
|
@@ -91,7 +91,7 @@ export function helpText(agents: string[], channel?: string): string {
|
|
|
91
91
|
"",
|
|
92
92
|
"Agent 给选项时会发一张提问卡,点按钮作答;都不合适就直接发消息,那条消息会被当成你的回答。",
|
|
93
93
|
"",
|
|
94
|
-
"**命令**(需整条消息只有命令;只发一个 `/`
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
912
|
+
let sessions = all ? [] : await list(conv.cwd);
|
|
895
913
|
const fellBack = !all && sessions.length === 0;
|
|
896
|
-
if (all || fellBack) sessions = await
|
|
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
|
-
|
|
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: {
|
|
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}。`);
|