agent-loft 0.0.0-stage → 0.2.8

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 (58) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +110 -2
  3. package/bin/cli.js +52 -0
  4. package/package.json +64 -4
  5. package/src/agents/claude/account.ts +122 -0
  6. package/src/agents/claude/driver.ts +216 -0
  7. package/src/agents/claude/errors.ts +129 -0
  8. package/src/agents/claude/permissions.ts +260 -0
  9. package/src/agents/claude/probe.ts +54 -0
  10. package/src/agents/claude/session.ts +487 -0
  11. package/src/agents/codex/account.ts +122 -0
  12. package/src/agents/codex/app-server.ts +346 -0
  13. package/src/agents/codex/driver.ts +210 -0
  14. package/src/agents/codex/errors.ts +158 -0
  15. package/src/agents/codex/protocol.ts +284 -0
  16. package/src/agents/codex/refusals.ts +62 -0
  17. package/src/agents/codex/session.ts +773 -0
  18. package/src/channels/feishu/bot.ts +669 -0
  19. package/src/channels/feishu/cards.ts +360 -0
  20. package/src/channels/feishu/client.ts +185 -0
  21. package/src/channels/feishu/envelope.ts +117 -0
  22. package/src/channels/feishu/index.ts +62 -0
  23. package/src/channels/feishu/message.ts +115 -0
  24. package/src/channels/feishu/register.ts +171 -0
  25. package/src/channels/feishu/slash-sync.ts +192 -0
  26. package/src/channels/feishu/turn-stream.ts +98 -0
  27. package/src/channels/web/api.ts +645 -0
  28. package/src/channels/web/auth.ts +204 -0
  29. package/src/channels/web/http.ts +118 -0
  30. package/src/channels/web/push.ts +218 -0
  31. package/src/channels/web/server.ts +446 -0
  32. package/src/cli/doctor.ts +39 -0
  33. package/src/cli/import.ts +155 -0
  34. package/src/cli/pair.ts +35 -0
  35. package/src/cli/passwd.ts +15 -0
  36. package/src/cli/service.ts +137 -0
  37. package/src/core/agent-env.ts +11 -0
  38. package/src/core/command-catalog.ts +112 -0
  39. package/src/core/commands.ts +104 -0
  40. package/src/core/config.ts +285 -0
  41. package/src/core/dedup.ts +16 -0
  42. package/src/core/footers.ts +88 -0
  43. package/src/core/hub.ts +1152 -0
  44. package/src/core/interactions.ts +273 -0
  45. package/src/core/pairing.ts +156 -0
  46. package/src/core/store.ts +178 -0
  47. package/src/core/types.ts +213 -0
  48. package/src/core/uploads.ts +200 -0
  49. package/src/home.ts +43 -0
  50. package/src/main.ts +68 -0
  51. package/web/app.js +3731 -0
  52. package/web/apple-touch-icon.png +0 -0
  53. package/web/icon-maskable.svg +13 -0
  54. package/web/icon.svg +12 -0
  55. package/web/index.html +23 -0
  56. package/web/manifest.webmanifest +15 -0
  57. package/web/style.css +2604 -0
  58. package/web/sw.js +76 -0
@@ -0,0 +1,446 @@
1
+ // Web 渠道:静态页面 + REST + WebSocket。
2
+ //
3
+ // WebSocket 上跑的是实时的东西:订阅某条会话的事件流(断线重连按 seq 补发)、
4
+ // 发消息、点授权 / 作答。手机切到后台再回来,连接会断,重连后用 since 把错过的补上。
5
+ import fs from "node:fs";
6
+ import http from "node:http";
7
+ import path from "node:path";
8
+ import { createRequire } from "node:module";
9
+ import { fileURLToPath } from "node:url";
10
+ import { WebSocketServer, type WebSocket } from "ws";
11
+ import { config } from "../../core/config.js";
12
+ import { parseCommand } from "../../core/commands.js";
13
+ import { hub, type EventEnvelope } from "../../core/hub.js";
14
+ import { interactions, type Interaction } from "../../core/interactions.js";
15
+ import { getConversation } from "../../core/store.js";
16
+ import type { Settlement } from "../../core/types.js";
17
+ import { authed, handleApi, takeUploads } from "./api.js";
18
+ import { authEvents, ensureSetupToken, validateSession } from "./auth.js";
19
+ import { cookies } from "./http.js";
20
+ import { COOKIE } from "./api.js";
21
+ import { HttpError, sameOrigin, SECURITY_HEADERS, sendJson } from "./http.js";
22
+ import { hasSubscriptions, pushToAll } from "./push.js";
23
+
24
+ const require = createRequire(import.meta.url);
25
+ const webRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../web");
26
+
27
+ /** 包的根目录。有的包(dompurify)的 exports 没放出 package.json,只能从入口往上找。 */
28
+ function pkgDir(pkg: string): string {
29
+ try {
30
+ return path.dirname(require.resolve(`${pkg}/package.json`));
31
+ } catch {
32
+ let dir = path.dirname(require.resolve(pkg));
33
+ while (!fs.existsSync(path.join(dir, "package.json")) && path.dirname(dir) !== dir) dir = path.dirname(dir);
34
+ return dir;
35
+ }
36
+ }
37
+
38
+ function pkgFile(pkg: string, rel: string): string {
39
+ return path.join(pkgDir(pkg), rel);
40
+ }
41
+
42
+ /** 前端依赖直接从 node_modules 里给,不走 CDN(国内访问 CDN 不稳定) */
43
+ const VENDOR: Record<string, () => string> = {
44
+ "/vendor/marked.js": () => pkgFile("marked", "lib/marked.esm.js"),
45
+ "/vendor/purify.js": () => pkgFile("dompurify", "dist/purify.es.mjs"),
46
+ "/vendor/hljs.js": () => pkgFile("@highlightjs/cdn-assets", "es/highlight.min.js"),
47
+ "/vendor/hljs-light.css": () => pkgFile("@highlightjs/cdn-assets", "styles/github.min.css"),
48
+ "/vendor/hljs-dark.css": () => pkgFile("@highlightjs/cdn-assets", "styles/github-dark.min.css"),
49
+ };
50
+
51
+ const MIME: Record<string, string> = {
52
+ ".html": "text/html; charset=utf-8",
53
+ ".js": "text/javascript; charset=utf-8",
54
+ ".mjs": "text/javascript; charset=utf-8",
55
+ ".css": "text/css; charset=utf-8",
56
+ ".svg": "image/svg+xml",
57
+ ".png": "image/png",
58
+ ".json": "application/json",
59
+ ".webmanifest": "application/manifest+json",
60
+ };
61
+
62
+ function serveFile(res: http.ServerResponse, file: string, cache: string): void {
63
+ fs.stat(file, (err, stat) => {
64
+ if (err || !stat.isFile()) {
65
+ res.writeHead(404, SECURITY_HEADERS);
66
+ res.end("not found");
67
+ return;
68
+ }
69
+ res.writeHead(200, {
70
+ ...SECURITY_HEADERS,
71
+ "Content-Type": MIME[path.extname(file)] ?? "application/octet-stream",
72
+ "Content-Length": stat.size,
73
+ "Cache-Control": cache,
74
+ });
75
+ fs.createReadStream(file).pipe(res);
76
+ });
77
+ }
78
+
79
+ function serveStatic(req: http.IncomingMessage, res: http.ServerResponse, url: URL): void {
80
+ const vendor = VENDOR[url.pathname];
81
+ if (vendor) {
82
+ try {
83
+ serveFile(res, vendor(), "public, max-age=86400");
84
+ } catch {
85
+ res.writeHead(404);
86
+ res.end();
87
+ }
88
+ return;
89
+ }
90
+ let rel: string;
91
+ try {
92
+ rel = decodeURIComponent(url.pathname);
93
+ } catch {
94
+ res.writeHead(400, SECURITY_HEADERS);
95
+ res.end("bad request");
96
+ return;
97
+ }
98
+ if (rel.includes("\0")) {
99
+ res.writeHead(400, SECURITY_HEADERS);
100
+ res.end("bad request");
101
+ return;
102
+ }
103
+ if (rel === "/" || !path.extname(rel)) rel = "/index.html"; // 单页应用
104
+ const file = path.resolve(webRoot, `.${rel}`);
105
+ if (!file.startsWith(webRoot + path.sep)) {
106
+ res.writeHead(403);
107
+ res.end();
108
+ return;
109
+ }
110
+ // 页面本身别缓存:升级后要立刻生效
111
+ serveFile(res, file, rel === "/index.html" || rel === "/sw.js" ? "no-cache" : "public, max-age=300");
112
+ void req;
113
+ }
114
+
115
+ // ---------- WebSocket ----------
116
+
117
+ type Client = {
118
+ ws: WebSocket;
119
+ subs: Set<string>;
120
+ alive: boolean;
121
+ token: string;
122
+ /** 页面在前台,且正看着哪条会话(前端在切会话、切前后台时上报) */
123
+ visible: boolean;
124
+ viewing: string | null;
125
+ };
126
+
127
+ /** 每次进程启动换一个:客户端发现变了,就知道 seq 已经从头计数,得整页重拉 */
128
+ const EPOCH = Math.random().toString(36).slice(2, 10);
129
+
130
+ /** 登录态已失效的连接:断开。客户端收到 4401 会回到登录页 */
131
+ function checkClient(client: Client): boolean {
132
+ if (validateSession(client.token)) return true;
133
+ client.ws.close(4401, "登录已失效");
134
+ clients.delete(client);
135
+ return false;
136
+ }
137
+ const clients = new Set<Client>();
138
+
139
+ function send(client: Client, msg: unknown): void {
140
+ if (client.ws.readyState === client.ws.OPEN) client.ws.send(JSON.stringify(msg));
141
+ }
142
+
143
+ function broadcastEvent(env: EventEnvelope): void {
144
+ // 侧边栏需要知道哪条会话有动静;正文只推给订阅了的
145
+ const listChanged = ["user", "meta", "state", "result", "error"].includes(env.ev.type);
146
+ const info = listChanged ? hub.info(env.key) : undefined;
147
+ for (const client of [...clients]) {
148
+ if (client.subs.has(env.key)) send(client, { t: "ev", ...env });
149
+ if (info) send(client, { t: "conv", info });
150
+ }
151
+ }
152
+
153
+ function broadcastInteraction(i: Interaction): void {
154
+ for (const client of clients) send(client, { t: "ix", interaction: i });
155
+ }
156
+
157
+ // ---------- 系统通知(Web Push) ----------
158
+
159
+ /** 有页面正在前台看着这条会话:人就在眼前,不用推 */
160
+ function watching(key: string): boolean {
161
+ for (const client of clients) if (client.visible && client.viewing === key) return true;
162
+ return false;
163
+ }
164
+
165
+ /** 通知正文用:去掉 markdown 记号,压成一行 */
166
+ export function plainPreview(text: string, max = 120): string {
167
+ const plain = text
168
+ .replace(/```[\s\S]*?```/g, " [代码] ")
169
+ .replace(/!\[[^\]]*\]\([^)]*\)/g, " [图片] ")
170
+ .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
171
+ .replace(/[`*_~#>|]/g, "")
172
+ .replace(/\s+/g, " ")
173
+ .trim();
174
+ return plain.length > max ? `${plain.slice(0, max - 1)}…` : plain;
175
+ }
176
+
177
+ function convName(key: string): string {
178
+ const conv = getConversation(key);
179
+ if (!conv) return "Agent Loft";
180
+ return conv.title?.trim() || hub.label(conv.agent);
181
+ }
182
+
183
+ /** 只推 Web 自己的会话:飞书会话的动静飞书已经会提醒 */
184
+ function notifiable(key: string): boolean {
185
+ return getConversation(key)?.channel === "web" && hasSubscriptions() && !watching(key);
186
+ }
187
+
188
+ function pushEvent(env: EventEnvelope): void {
189
+ const ev = env.ev;
190
+ if (ev.type !== "result" && ev.type !== "error") return;
191
+ if (!notifiable(env.key)) return;
192
+ const name = convName(env.key);
193
+ const payload =
194
+ ev.type === "result"
195
+ ? { title: `${name} · 回复完成`, body: plainPreview(ev.text) || "(没有文字回复)" }
196
+ : { title: `${name} · 出错了`, body: plainPreview(ev.message) };
197
+ void pushToAll({ ...payload, tag: `reply:${env.key}`, url: `/#${encodeURIComponent(env.key)}` });
198
+ }
199
+
200
+ function pushInteraction(i: Interaction): void {
201
+ if (!notifiable(i.convKey)) return;
202
+ const name = convName(i.convKey);
203
+ const payload =
204
+ i.spec.kind === "approval"
205
+ ? { title: `${name} · 需要授权`, body: plainPreview(i.spec.title) }
206
+ : { title: `${name} · 有问题要你回答`, body: plainPreview(i.spec.questions[0]?.question ?? i.spec.title) };
207
+ void pushToAll({ ...payload, tag: `ix:${i.id}`, url: `/#${encodeURIComponent(i.convKey)}` });
208
+ }
209
+
210
+ const BY = "已在 Web 处理";
211
+
212
+ function handleClientMessage(client: Client, raw: string): void {
213
+ // 每条消息都复查登录态:握手时有效不代表现在还有效(改了密码、被踢了)
214
+ if (!checkClient(client)) return;
215
+ let msg: Record<string, unknown>;
216
+ try {
217
+ const parsed: unknown = JSON.parse(raw);
218
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return;
219
+ msg = parsed as Record<string, unknown>;
220
+ } catch {
221
+ return;
222
+ }
223
+ const rid = msg.rid;
224
+ const ack = (ok: boolean, extra: Record<string, unknown> = {}) => send(client, { t: "ack", rid, ok, ...extra });
225
+ const key = typeof msg.key === "string" ? msg.key : "";
226
+
227
+ try {
228
+ switch (msg.t) {
229
+ case "ping":
230
+ send(client, { t: "pong" });
231
+ return;
232
+
233
+ case "sub": {
234
+ if (!getConversation(key)) return ack(false, { error: "会话不存在" });
235
+ client.subs.add(key);
236
+ const since = typeof msg.since === "number" ? msg.since : 0;
237
+ // 客户端的 seq 来自上一个进程(比现在的还大),或者换了实例:补不了,整页重拉
238
+ const staleEpoch = typeof msg.epoch === "string" && msg.epoch !== EPOCH;
239
+ // since=0:刚打开,历史由 REST 拉,这里只补正在进行的那一轮
240
+ const events =
241
+ staleEpoch || since > hub.currentSeq() ? null : since > 0 ? hub.eventsSince(key, since) : hub.liveTurn(key);
242
+ send(client, {
243
+ t: "replay",
244
+ epoch: EPOCH,
245
+ key,
246
+ reset: events === null,
247
+ events: events ?? [],
248
+ seq: hub.currentSeq(),
249
+ busy: hub.isBusy(key),
250
+ pending: interactions.listPending(key),
251
+ });
252
+ return ack(true);
253
+ }
254
+
255
+ case "presence":
256
+ client.visible = msg.visible === true;
257
+ client.viewing = typeof msg.key === "string" && msg.key ? msg.key : null;
258
+ return;
259
+
260
+ case "unsub":
261
+ client.subs.delete(key);
262
+ return ack(true);
263
+
264
+ case "send": {
265
+ if (!getConversation(key)) return ack(false, { error: "会话不存在" });
266
+ const text = typeof msg.text === "string" ? msg.text : "";
267
+ const attachments = takeUploads(key, msg.uploads);
268
+ if (!text.trim() && attachments.length === 0) return ack(false, { error: "空消息" });
269
+ // 和飞书一致:整条消息是 agent-loft 命令就由 hub 执行,结果随 ack 回给发命令的这个页面,
270
+ // 不进会话记录。带附件的一律当聊天。
271
+ const command = attachments.length > 0 ? null : parseCommand(text);
272
+ if (command && command.kind !== "chat") {
273
+ const allowedAgents = hub.enabledAgents().map((d) => d.id);
274
+ void hub
275
+ .command(key, command, { allowedAgents, by: "Web 命令" })
276
+ .then((reply) => ack(true, { reply, info: hub.info(key) }))
277
+ .catch((err) => ack(false, { error: err instanceof Error ? err.message : String(err) }));
278
+ return;
279
+ }
280
+ hub.send(key, { text, attachments }, { channel: "web", channelId: "web", name: "Web" });
281
+ return ack(true);
282
+ }
283
+
284
+ case "ix": {
285
+ const id = String(msg.id ?? "");
286
+ let ok = false;
287
+ let error: string | undefined;
288
+ switch (msg.op) {
289
+ case "decide":
290
+ ok = !!interactions.decide(id, String(msg.decision) as Settlement, BY);
291
+ break;
292
+ case "select":
293
+ ok = !!interactions.select(id, Number(msg.q), Number(msg.o), msg.v !== false, BY);
294
+ break;
295
+ case "submit": {
296
+ const r = interactions.submit(id, BY);
297
+ ok = !!r && !("missing" in r);
298
+ if (r && "missing" in r) error = "还有问题没选";
299
+ break;
300
+ }
301
+ case "skip":
302
+ ok = !!interactions.skip(id, "已在 Web 跳过,agent 会按合理的默认继续");
303
+ break;
304
+ case "text":
305
+ ok = interactions.answerWithText(id, String(msg.text ?? ""), BY);
306
+ break;
307
+ }
308
+ if (!ok && !error) {
309
+ const settled = interactions.get(id)?.settled;
310
+ error = settled ? `已经处理过了(${settled.by})` : "请求已失效";
311
+ }
312
+ return ack(ok, error ? { error } : {});
313
+ }
314
+ }
315
+ } catch (err) {
316
+ ack(false, { error: err instanceof Error ? err.message : String(err) });
317
+ }
318
+ }
319
+
320
+ // ---------- 启动 ----------
321
+
322
+ let server: http.Server | null = null;
323
+ let wss: WebSocketServer | null = null;
324
+ let heartbeat: NodeJS.Timeout | null = null;
325
+
326
+ export async function startWeb(): Promise<void> {
327
+ const { host, port } = config().web;
328
+
329
+ server = http.createServer((req, res) => {
330
+ let url: URL;
331
+ try {
332
+ url = new URL(req.url ?? "/", "http://local");
333
+ } catch {
334
+ res.writeHead(400);
335
+ res.end();
336
+ return;
337
+ }
338
+ if (!url.pathname.startsWith("/api/")) {
339
+ try {
340
+ serveStatic(req, res, url);
341
+ } catch (err) {
342
+ console.error("[web] 静态文件出错:", err);
343
+ if (!res.headersSent) res.writeHead(500);
344
+ res.end();
345
+ }
346
+ return;
347
+ }
348
+ if (req.method !== "GET" && !sameOrigin(req)) {
349
+ sendJson(res, 403, { error: "跨站请求被拒绝" });
350
+ return;
351
+ }
352
+ for (const [k, v] of Object.entries(SECURITY_HEADERS)) res.setHeader(k, v);
353
+ handleApi(req, res, url).catch((err) => {
354
+ const status = err instanceof HttpError ? err.status : 500;
355
+ if (status === 500) console.error("[web] 接口出错:", err);
356
+ if (!res.headersSent) sendJson(res, status, { error: err instanceof Error ? err.message : String(err) });
357
+ else res.end();
358
+ });
359
+ });
360
+
361
+ wss = new WebSocketServer({ noServer: true, maxPayload: 1024 * 1024 });
362
+ server.on("upgrade", (req, socket, head) => {
363
+ try {
364
+ const url = new URL(req.url ?? "/", "http://local");
365
+ if (url.pathname !== "/ws" || !sameOrigin(req) || !authed(req)) {
366
+ socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
367
+ socket.destroy();
368
+ return;
369
+ }
370
+ const token = cookies(req)[COOKIE] ?? "";
371
+ wss!.handleUpgrade(req, socket, head, (ws) => {
372
+ const client: Client = { ws, subs: new Set(), alive: true, token, visible: false, viewing: null };
373
+ clients.add(client);
374
+ ws.on("pong", () => (client.alive = true));
375
+ ws.on("message", (data) => {
376
+ try {
377
+ handleClientMessage(client, data.toString());
378
+ } catch (err) {
379
+ console.error("[web] 处理 WebSocket 消息出错:", err);
380
+ }
381
+ });
382
+ ws.on("close", () => clients.delete(client));
383
+ ws.on("error", () => clients.delete(client));
384
+ send(client, { t: "hello", seq: hub.currentSeq(), epoch: EPOCH });
385
+ });
386
+ } catch (err) {
387
+ console.error("[web] WebSocket 握手出错:", err);
388
+ socket.destroy();
389
+ }
390
+ });
391
+ authEvents.on("revoked", onRevoked);
392
+
393
+ // 反代 / 手机网络会悄悄掐掉空闲连接,定期 ping 一下把死连接清掉
394
+ heartbeat = setInterval(() => {
395
+ for (const client of [...clients]) {
396
+ if (!checkClient(client)) continue;
397
+ if (!client.alive) {
398
+ client.ws.terminate();
399
+ clients.delete(client);
400
+ continue;
401
+ }
402
+ client.alive = false;
403
+ client.ws.ping();
404
+ }
405
+ }, 30_000);
406
+ heartbeat.unref();
407
+
408
+ hub.on("event", broadcastEvent);
409
+ hub.on("event", pushEvent);
410
+ interactions.on("created", broadcastInteraction);
411
+ interactions.on("created", pushInteraction);
412
+ interactions.on("updated", broadcastInteraction);
413
+ interactions.on("settled", broadcastInteraction);
414
+
415
+ await new Promise<void>((resolve, reject) => {
416
+ server!.once("error", reject);
417
+ server!.listen(port, host, () => resolve());
418
+ });
419
+ console.log(`[web] 已监听 http://${host}:${port}`);
420
+ const token = ensureSetupToken();
421
+ if (token) {
422
+ console.log("=".repeat(56));
423
+ console.log(`[web] 还没有设置登录密码。打开页面后输入设置码:${token}`);
424
+ console.log("=".repeat(56));
425
+ }
426
+ }
427
+
428
+ function onRevoked(): void {
429
+ for (const client of [...clients]) checkClient(client);
430
+ }
431
+
432
+ export async function stopWeb(): Promise<void> {
433
+ authEvents.off("revoked", onRevoked);
434
+ hub.off("event", broadcastEvent);
435
+ hub.off("event", pushEvent);
436
+ interactions.off("created", broadcastInteraction);
437
+ interactions.off("created", pushInteraction);
438
+ interactions.off("updated", broadcastInteraction);
439
+ interactions.off("settled", broadcastInteraction);
440
+ if (heartbeat) clearInterval(heartbeat);
441
+ for (const client of clients) client.ws.close(1001, "服务重启");
442
+ clients.clear();
443
+ wss?.close();
444
+ await new Promise<void>((resolve) => (server ? server.close(() => resolve()) : resolve()));
445
+ server = null;
446
+ }
@@ -0,0 +1,39 @@
1
+ // agent-loft doctor:配置 / agent / 飞书连通性体检。
2
+ import { spawnSync } from "node:child_process";
3
+ import { config, loadConfig } from "../core/config.js";
4
+ import { FeishuClient } from "../channels/feishu/client.js";
5
+ import { configPath } from "../home.js";
6
+
7
+ loadConfig();
8
+ const cfg = config();
9
+ const ok = (s: string) => console.log(` ✅ ${s}`);
10
+ const bad = (s: string) => console.log(` ❌ ${s}`);
11
+ const warn = (s: string) => console.log(` ⚠️ ${s}`);
12
+
13
+ console.log(`配置文件:${configPath}`);
14
+ console.log(`工作目录:${cfg.workspaceRoot}`);
15
+
16
+ console.log("\nAgent");
17
+ if (cfg.agents.claude.enabled) {
18
+ const r = spawnSync("claude", ["--version"], { encoding: "utf8" });
19
+ if (r.status === 0) ok(`Claude Code ${r.stdout.trim()}`);
20
+ else warn("没找到 claude 命令(SDK 自带运行时,通常不影响;登录态需要先在终端 claude 里登录过)");
21
+ } else warn("Claude 未启用");
22
+ if (cfg.agents.codex.enabled) {
23
+ const r = spawnSync(cfg.agents.codex.path, ["--version"], { encoding: "utf8" });
24
+ if (r.status === 0) ok(`Codex ${r.stdout.trim()}(沙箱 ${cfg.agents.codex.sandbox})`);
25
+ else bad(`找不到 ${cfg.agents.codex.path},到设置里填 codex 的绝对路径`);
26
+ } else warn("Codex 未启用");
27
+
28
+ console.log("\n飞书机器人");
29
+ if (cfg.feishu.bots.length === 0) warn("还没有配置飞书机器人(Web 设置页可以扫码创建)");
30
+ for (const bot of cfg.feishu.bots) {
31
+ const probe = await new FeishuClient(bot.appId, bot.appSecret, bot.domain, bot.name).probe();
32
+ const line = `${bot.name}(${bot.appId},agent:${bot.agents.join("、")})${bot.enabled ? "" : " [未启用]"}`;
33
+ if (probe.ok) ok(line);
34
+ else bad(`${line}:${probe.message}`);
35
+ if (bot.allowFrom.length === 0) warn(`${bot.name} 的白名单为空:只能靠配对使用`);
36
+ }
37
+
38
+ console.log("\nWeb");
39
+ console.log(` ${cfg.web.enabled ? `http://${cfg.web.host}:${cfg.web.port}` : "未启用"}${cfg.web.passwordHash ? "" : "(还没设密码,首次打开需要启动日志里的设置码)"}`);
@@ -0,0 +1,155 @@
1
+ // 从旧桥接器导入:feishu-claude-bridge(~/.feishu-claude-bridge)和 feishu-codex-bridge(~/.feishu-codex-bridge)。
2
+ //
3
+ // 导入飞书机器人凭据、白名单、配对、会话绑定(飞书群里接着原来的会话聊)、
4
+ // Claude 的「总是允许」名单,以及工作目录 / 默认模型 / 沙箱等设置。
5
+ //
6
+ // 用法:
7
+ // agent-loft import 两个都导
8
+ // agent-loft import --only codex 只导其中一个
9
+ // agent-loft import --disabled 导入但先不启用机器人(新旧并行时避免抢同一个机器人的消息)
10
+ // agent-loft import --dry-run 只看会导入什么
11
+ import fs from "node:fs";
12
+ import os from "node:os";
13
+ import path from "node:path";
14
+ import { config, loadConfig, saveConfig, type FeishuBotConfig } from "../core/config.js";
15
+ import { getConversation, putConversation, type Conversation } from "../core/store.js";
16
+ import { addApproved } from "../core/pairing.js";
17
+ import { listAllowlist, setAllowlist } from "../agents/claude/permissions.js";
18
+ import { readJson } from "../home.js";
19
+
20
+ const args = process.argv.slice(2);
21
+ const flag = (name: string) => args.includes(`--${name}`);
22
+ const value = (name: string) => {
23
+ const at = args.indexOf(`--${name}`);
24
+ return at >= 0 ? args[at + 1] : undefined;
25
+ };
26
+ const only = value("only");
27
+ const dryRun = flag("dry-run");
28
+ const disabled = flag("disabled");
29
+
30
+ function parseEnv(file: string): Record<string, string> {
31
+ const out: Record<string, string> = {};
32
+ let text: string;
33
+ try {
34
+ text = fs.readFileSync(file, "utf8");
35
+ } catch {
36
+ return out;
37
+ }
38
+ for (const line of text.split("\n")) {
39
+ const m = /^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/.exec(line);
40
+ if (!m) continue;
41
+ out[m[1]!] = m[2]!.replace(/^["']|["']$/g, "");
42
+ }
43
+ return out;
44
+ }
45
+
46
+ type OldBinding = {
47
+ sessionId?: string;
48
+ threadId?: string;
49
+ cwd: string;
50
+ cwdExplicit?: boolean;
51
+ model?: string;
52
+ effort?: string;
53
+ updatedAt: number;
54
+ };
55
+
56
+ function importOne(kind: "claude" | "codex"): void {
57
+ const dir = path.join(os.homedir(), kind === "claude" ? ".feishu-claude-bridge" : ".feishu-codex-bridge");
58
+ const env = parseEnv(path.join(dir, ".env"));
59
+ if (!env.FEISHU_APP_ID || !env.FEISHU_APP_SECRET) {
60
+ console.log(`- ${dir} 没有可用的配置,跳过`);
61
+ return;
62
+ }
63
+ const cfg = structuredClone(config());
64
+ const botId = `${kind}_bot`;
65
+ const existing = cfg.feishu.bots.find((b) => b.appId === env.FEISHU_APP_ID);
66
+ const id = existing?.id ?? botId;
67
+ const bot: FeishuBotConfig = {
68
+ id,
69
+ name: existing?.name ?? (kind === "claude" ? "Claude 机器人" : "Codex 机器人"),
70
+ enabled: !disabled,
71
+ appId: env.FEISHU_APP_ID,
72
+ appSecret: env.FEISHU_APP_SECRET,
73
+ domain: env.FEISHU_DOMAIN === "lark" ? "lark" : "feishu",
74
+ allowFrom: (env.FEISHU_ALLOW_FROM ?? "")
75
+ .split(",")
76
+ .map((s) => s.trim().toLowerCase())
77
+ .filter((s) => s.startsWith("ou_")),
78
+ agents: existing?.agents ?? [kind],
79
+ defaultAgent: existing?.defaultAgent ?? kind,
80
+ };
81
+ console.log(`- 飞书机器人「${bot.name}」 appId=${bot.appId} agent=${bot.agents.join(",")} 白名单 ${bot.allowFrom.length} 人${disabled ? "(暂不启用)" : ""}`);
82
+
83
+ if (env.BRIDGE_WORKSPACE_ROOT) cfg.workspaceRoot = env.BRIDGE_WORKSPACE_ROOT;
84
+ if (env.BRIDGE_APPROVAL_TIMEOUT_MS) cfg.approvalTimeoutMs = Number(env.BRIDGE_APPROVAL_TIMEOUT_MS);
85
+ if (env.BRIDGE_SESSION_TTL_MS) cfg.sessionTtlMs = Number(env.BRIDGE_SESSION_TTL_MS);
86
+ if (kind === "claude") {
87
+ if (env.BRIDGE_DEFAULT_MODEL) cfg.agents.claude.defaultModel = env.BRIDGE_DEFAULT_MODEL;
88
+ } else {
89
+ if (env.BRIDGE_CODEX_PATH) cfg.agents.codex.path = env.BRIDGE_CODEX_PATH;
90
+ if (env.BRIDGE_CODEX_HOME) cfg.agents.codex.home = env.BRIDGE_CODEX_HOME;
91
+ if (env.BRIDGE_SANDBOX) cfg.agents.codex.sandbox = env.BRIDGE_SANDBOX as typeof cfg.agents.codex.sandbox;
92
+ if (env.BRIDGE_DEFAULT_MODEL) cfg.agents.codex.defaultModel = env.BRIDGE_DEFAULT_MODEL;
93
+ if (env.BRIDGE_DEFAULT_EFFORT) cfg.agents.codex.defaultEffort = env.BRIDGE_DEFAULT_EFFORT;
94
+ }
95
+ cfg.feishu.bots = [...cfg.feishu.bots.filter((b) => b.id !== id), bot];
96
+
97
+ const sessions = readJson<Record<string, OldBinding>>(path.join(dir, "data", "sessions.json"), {});
98
+ const pairing = readJson<{ approved?: string[] }>(path.join(dir, "data", "pairing.json"), {});
99
+ const allowlist = kind === "claude" ? readJson<string[]>(path.join(dir, "data", "allowlist.json"), []) : [];
100
+ console.log(` 会话 ${Object.keys(sessions).length} 条,配对用户 ${pairing.approved?.length ?? 0} 人${kind === "claude" ? `,总是允许 ${allowlist.length} 条` : ""}`);
101
+ if (dryRun) return;
102
+
103
+ saveConfig(cfg);
104
+ for (const [oldKey, b] of Object.entries(sessions)) {
105
+ const key = `feishu:${id}:${oldKey}`;
106
+ if (getConversation(key)) continue;
107
+ const sessionId = b.sessionId ?? b.threadId;
108
+ const conv: Conversation = {
109
+ key,
110
+ channel: "feishu",
111
+ channelId: id,
112
+ agent: kind,
113
+ cwd: b.cwd || config().workspaceRoot,
114
+ ...(b.cwdExplicit ? { cwdExplicit: true } : {}),
115
+ perAgent: {
116
+ [kind]: {
117
+ ...(sessionId ? { sessionId } : {}),
118
+ ...(b.model ? { model: b.model } : {}),
119
+ ...(b.effort ? { effort: b.effort } : {}),
120
+ },
121
+ },
122
+ activeAt: b.updatedAt || Date.now(),
123
+ createdAt: b.updatedAt || Date.now(),
124
+ };
125
+ putConversation(conv);
126
+ }
127
+ for (const userId of pairing.approved ?? []) addApproved(id, userId);
128
+ if (allowlist.length) setAllowlist([...listAllowlist(), ...allowlist]);
129
+ }
130
+
131
+ loadConfig();
132
+
133
+ // 正在运行的实例内存里有一份会话缓存,下次写盘会把这里导入的覆盖掉
134
+ async function instanceRunning(): Promise<boolean> {
135
+ const { host, port } = config().web;
136
+ if (!config().web.enabled) return false;
137
+ try {
138
+ await fetch(`http://${host === "0.0.0.0" ? "127.0.0.1" : host}:${port}/api/me`, { signal: AbortSignal.timeout(1500) });
139
+ return true;
140
+ } catch {
141
+ return false;
142
+ }
143
+ }
144
+ if (!dryRun && !flag("force") && (await instanceRunning())) {
145
+ throw new Error("agent-loft 正在运行。请先停止它再导入(agent-loft service 管理的话:systemctl --user stop agent-loft),导完再启动。");
146
+ }
147
+
148
+ console.log(dryRun ? "将要导入(dry-run,不落盘):" : "导入中:");
149
+ if (!only || only === "claude") importOne("claude");
150
+ if (!only || only === "codex") importOne("codex");
151
+ console.log(
152
+ dryRun
153
+ ? "\n去掉 --dry-run 正式导入。"
154
+ : "\n完成。注意:同一个飞书机器人不能被两个程序同时连接,正式切换前请先停掉旧的桥接器服务:\n systemctl --user disable --now feishu-claude-bridge feishu-codex-bridge",
155
+ );
@@ -0,0 +1,35 @@
1
+ // agent-loft pair list | approve <配对码> | revoke <机器人id> <open_id>
2
+ import { approve, listApproved, listPending, revoke } from "../core/pairing.js";
3
+ import { loadConfig } from "../core/config.js";
4
+
5
+ loadConfig();
6
+ const [sub, ...rest] = process.argv.slice(2);
7
+
8
+ switch (sub) {
9
+ case "approve": {
10
+ const code = rest[0];
11
+ if (!code) throw new Error("用法:agent-loft pair approve <配对码>");
12
+ const result = approve(code);
13
+ if (!result.ok) throw new Error(`配对码 ${code} 不存在或已过期`);
14
+ console.log(`已批准 ${result.request.userId}(机器人 ${result.channelId})。正在运行的 agent-loft 会通知对方。`);
15
+ break;
16
+ }
17
+ case "revoke": {
18
+ const [channelId, userId] = rest;
19
+ if (!channelId || !userId) throw new Error("用法:agent-loft pair revoke <机器人id> <open_id>");
20
+ console.log(revoke(channelId, userId) ? "已撤销" : "没有这个已配对用户");
21
+ break;
22
+ }
23
+ default: {
24
+ const pending = listPending();
25
+ console.log(pending.length ? "待批准:" : "没有待批准的请求。");
26
+ for (const { channelId, request } of pending) {
27
+ console.log(` ${request.code} ${request.userId} 机器人 ${channelId} ${new Date(request.createdAt).toLocaleString()}`);
28
+ }
29
+ const approved = listApproved();
30
+ if (approved.length) {
31
+ console.log("已配对:");
32
+ for (const a of approved) console.log(` ${a.userId} 机器人 ${a.channelId}`);
33
+ }
34
+ }
35
+ }