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.
- package/LICENSE +21 -0
- package/README.md +110 -2
- package/bin/cli.js +52 -0
- package/package.json +64 -4
- package/src/agents/claude/account.ts +122 -0
- package/src/agents/claude/driver.ts +216 -0
- package/src/agents/claude/errors.ts +129 -0
- package/src/agents/claude/permissions.ts +260 -0
- package/src/agents/claude/probe.ts +54 -0
- package/src/agents/claude/session.ts +487 -0
- package/src/agents/codex/account.ts +122 -0
- package/src/agents/codex/app-server.ts +346 -0
- package/src/agents/codex/driver.ts +210 -0
- package/src/agents/codex/errors.ts +158 -0
- package/src/agents/codex/protocol.ts +284 -0
- package/src/agents/codex/refusals.ts +62 -0
- package/src/agents/codex/session.ts +773 -0
- package/src/channels/feishu/bot.ts +669 -0
- package/src/channels/feishu/cards.ts +360 -0
- package/src/channels/feishu/client.ts +185 -0
- package/src/channels/feishu/envelope.ts +117 -0
- package/src/channels/feishu/index.ts +62 -0
- package/src/channels/feishu/message.ts +115 -0
- package/src/channels/feishu/register.ts +171 -0
- package/src/channels/feishu/slash-sync.ts +192 -0
- package/src/channels/feishu/turn-stream.ts +98 -0
- package/src/channels/web/api.ts +645 -0
- package/src/channels/web/auth.ts +204 -0
- package/src/channels/web/http.ts +118 -0
- package/src/channels/web/push.ts +218 -0
- package/src/channels/web/server.ts +446 -0
- package/src/cli/doctor.ts +39 -0
- package/src/cli/import.ts +155 -0
- package/src/cli/pair.ts +35 -0
- package/src/cli/passwd.ts +15 -0
- package/src/cli/service.ts +137 -0
- package/src/core/agent-env.ts +11 -0
- package/src/core/command-catalog.ts +112 -0
- package/src/core/commands.ts +104 -0
- package/src/core/config.ts +285 -0
- package/src/core/dedup.ts +16 -0
- package/src/core/footers.ts +88 -0
- package/src/core/hub.ts +1152 -0
- package/src/core/interactions.ts +273 -0
- package/src/core/pairing.ts +156 -0
- package/src/core/store.ts +178 -0
- package/src/core/types.ts +213 -0
- package/src/core/uploads.ts +200 -0
- package/src/home.ts +43 -0
- package/src/main.ts +68 -0
- package/web/app.js +3731 -0
- package/web/apple-touch-icon.png +0 -0
- package/web/icon-maskable.svg +13 -0
- package/web/icon.svg +12 -0
- package/web/index.html +23 -0
- package/web/manifest.webmanifest +15 -0
- package/web/style.css +2604 -0
- package/web/sw.js +76 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// agent-loft passwd:在终端里重置 Web 登录密码(忘记密码时用)。所有已登录设备会下线。
|
|
2
|
+
import readline from "node:readline/promises";
|
|
3
|
+
import { loadConfig } from "../core/config.js";
|
|
4
|
+
import { setPassword } from "../channels/web/auth.js";
|
|
5
|
+
|
|
6
|
+
loadConfig();
|
|
7
|
+
const fromArg = process.argv[2];
|
|
8
|
+
let password = fromArg;
|
|
9
|
+
if (!password) {
|
|
10
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
11
|
+
password = await rl.question("新的 Web 登录密码(至少 8 位):");
|
|
12
|
+
rl.close();
|
|
13
|
+
}
|
|
14
|
+
setPassword(password.trim());
|
|
15
|
+
console.log("密码已更新,所有已登录的设备需要重新登录。");
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// agent-loft service install | uninstall | restart | status
|
|
2
|
+
//
|
|
3
|
+
// Linux 用 systemd 用户服务,macOS 用 launchd。只碰服务定义文件,不碰 ~/.agent-loft 里的配置和会话。
|
|
4
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import os from "node:os";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { home, logsDir } from "../home.js";
|
|
10
|
+
|
|
11
|
+
const UNIT = "agent-loft";
|
|
12
|
+
const LABEL = "com.agent-loft";
|
|
13
|
+
/** 改名前的服务名。装新服务时顺手卸掉,免得新旧两个进程抢同一个飞书机器人的消息 */
|
|
14
|
+
const LEGACY_UNIT = "im-agent";
|
|
15
|
+
const LEGACY_LABEL = "com.im-agent";
|
|
16
|
+
const entry = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../bin/cli.js");
|
|
17
|
+
|
|
18
|
+
function unitPath(): string {
|
|
19
|
+
return process.platform === "darwin"
|
|
20
|
+
? path.join(os.homedir(), "Library/LaunchAgents", `${LABEL}.plist`)
|
|
21
|
+
: path.join(os.homedir(), ".config/systemd/user", `${UNIT}.service`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** systemd 下 PATH 很短:把 node、claude、codex 所在目录都带上 */
|
|
25
|
+
function servicePath(): string {
|
|
26
|
+
const dirs = new Set<string>([path.dirname(process.execPath)]);
|
|
27
|
+
for (const bin of ["claude", "codex"]) {
|
|
28
|
+
const found = spawnSync("sh", ["-c", `command -v ${bin}`], { encoding: "utf8" }).stdout.trim();
|
|
29
|
+
if (found) dirs.add(path.dirname(found));
|
|
30
|
+
}
|
|
31
|
+
for (const d of (process.env.PATH ?? "").split(":")) if (d) dirs.add(d);
|
|
32
|
+
return [...dirs].join(":");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function systemdUnit(): string {
|
|
36
|
+
return `[Unit]
|
|
37
|
+
Description=agent-loft(飞书 / Web ↔ Claude Code / Codex)
|
|
38
|
+
After=network-online.target
|
|
39
|
+
# 默认「10 秒内重启 5 次就永久放弃」,那正是崩溃循环最需要它别放弃的时候
|
|
40
|
+
StartLimitIntervalSec=0
|
|
41
|
+
|
|
42
|
+
[Service]
|
|
43
|
+
Type=simple
|
|
44
|
+
ExecStart=${process.execPath} ${entry} start
|
|
45
|
+
WorkingDirectory=${home}
|
|
46
|
+
StandardOutput=append:${path.join(logsDir, "out.log")}
|
|
47
|
+
StandardError=append:${path.join(logsDir, "err.log")}
|
|
48
|
+
Environment=PATH=${servicePath()}
|
|
49
|
+
Environment=AGENT_LOFT_HOME=${home}
|
|
50
|
+
Restart=always
|
|
51
|
+
RestartSec=5
|
|
52
|
+
|
|
53
|
+
[Install]
|
|
54
|
+
WantedBy=default.target
|
|
55
|
+
`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function plist(): string {
|
|
59
|
+
const esc = (s: string) => s.replace(/&/g, "&").replace(/</g, "<");
|
|
60
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
61
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
62
|
+
<plist version="1.0"><dict>
|
|
63
|
+
<key>Label</key><string>${LABEL}</string>
|
|
64
|
+
<key>ProgramArguments</key><array><string>${esc(process.execPath)}</string><string>${esc(entry)}</string><string>start</string></array>
|
|
65
|
+
<key>RunAtLoad</key><true/>
|
|
66
|
+
<key>KeepAlive</key><true/>
|
|
67
|
+
<key>WorkingDirectory</key><string>${esc(home)}</string>
|
|
68
|
+
<key>StandardOutPath</key><string>${esc(path.join(logsDir, "out.log"))}</string>
|
|
69
|
+
<key>StandardErrorPath</key><string>${esc(path.join(logsDir, "err.log"))}</string>
|
|
70
|
+
<key>EnvironmentVariables</key><dict>
|
|
71
|
+
<key>PATH</key><string>${esc(servicePath())}</string>
|
|
72
|
+
<key>AGENT_LOFT_HOME</key><string>${esc(home)}</string>
|
|
73
|
+
</dict>
|
|
74
|
+
</dict></plist>
|
|
75
|
+
`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function removeLegacyService(): void {
|
|
79
|
+
if (process.platform === "darwin") {
|
|
80
|
+
const file = path.join(os.homedir(), "Library/LaunchAgents", `${LEGACY_LABEL}.plist`);
|
|
81
|
+
if (!fs.existsSync(file)) return;
|
|
82
|
+
spawnSync("launchctl", ["bootout", `gui/${process.getuid?.() ?? 0}/${LEGACY_LABEL}`]);
|
|
83
|
+
fs.rmSync(file, { force: true });
|
|
84
|
+
} else {
|
|
85
|
+
const file = path.join(os.homedir(), ".config/systemd/user", `${LEGACY_UNIT}.service`);
|
|
86
|
+
if (!fs.existsSync(file)) return;
|
|
87
|
+
spawnSync("systemctl", ["--user", "disable", "--now", LEGACY_UNIT], { stdio: "inherit" });
|
|
88
|
+
fs.rmSync(file, { force: true });
|
|
89
|
+
}
|
|
90
|
+
console.log(`已停用并移除旧的 ${LEGACY_UNIT} 服务(配置目录不动)`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function run(cmd: string, args: string[]): void {
|
|
94
|
+
execFileSync(cmd, args, { stdio: "inherit" });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const sub = process.argv[2] ?? "status";
|
|
98
|
+
fs.mkdirSync(logsDir, { recursive: true });
|
|
99
|
+
|
|
100
|
+
if (process.platform === "darwin") {
|
|
101
|
+
const uid = process.getuid?.() ?? 0;
|
|
102
|
+
if (sub === "install") {
|
|
103
|
+
removeLegacyService();
|
|
104
|
+
fs.mkdirSync(path.dirname(unitPath()), { recursive: true });
|
|
105
|
+
fs.writeFileSync(unitPath(), plist());
|
|
106
|
+
spawnSync("launchctl", ["bootout", `gui/${uid}/${LABEL}`]);
|
|
107
|
+
run("launchctl", ["bootstrap", `gui/${uid}`, unitPath()]);
|
|
108
|
+
console.log(`已安装并启动。日志:${logsDir}`);
|
|
109
|
+
} else if (sub === "uninstall") {
|
|
110
|
+
spawnSync("launchctl", ["bootout", `gui/${uid}/${LABEL}`]);
|
|
111
|
+
fs.rmSync(unitPath(), { force: true });
|
|
112
|
+
console.log("已卸载");
|
|
113
|
+
} else if (sub === "restart") {
|
|
114
|
+
run("launchctl", ["kickstart", "-k", `gui/${uid}/${LABEL}`]);
|
|
115
|
+
} else {
|
|
116
|
+
spawnSync("launchctl", ["print", `gui/${uid}/${LABEL}`], { stdio: "inherit" });
|
|
117
|
+
}
|
|
118
|
+
} else {
|
|
119
|
+
if (sub === "install") {
|
|
120
|
+
removeLegacyService();
|
|
121
|
+
fs.mkdirSync(path.dirname(unitPath()), { recursive: true });
|
|
122
|
+
fs.writeFileSync(unitPath(), systemdUnit());
|
|
123
|
+
run("systemctl", ["--user", "daemon-reload"]);
|
|
124
|
+
run("systemctl", ["--user", "enable", "--now", UNIT]);
|
|
125
|
+
console.log(`已安装并启动。日志:${logsDir}`);
|
|
126
|
+
console.log("想在没登录时也运行:sudo loginctl enable-linger $USER");
|
|
127
|
+
} else if (sub === "uninstall") {
|
|
128
|
+
spawnSync("systemctl", ["--user", "disable", "--now", UNIT], { stdio: "inherit" });
|
|
129
|
+
fs.rmSync(unitPath(), { force: true });
|
|
130
|
+
run("systemctl", ["--user", "daemon-reload"]);
|
|
131
|
+
console.log("已卸载");
|
|
132
|
+
} else if (sub === "restart") {
|
|
133
|
+
run("systemctl", ["--user", "restart", UNIT]);
|
|
134
|
+
} else {
|
|
135
|
+
spawnSync("systemctl", ["--user", "status", UNIT, "--no-pager"], { stdio: "inherit" });
|
|
136
|
+
}
|
|
137
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 交给 agent 子进程的环境变量。
|
|
3
|
+
*
|
|
4
|
+
* agent 可能会跑用户项目里的脚本,那些脚本也可能读 FEISHU_APP_ID 之类的变量 ——
|
|
5
|
+
* 我们自己的凭据不能漏进去。复制而不是改 process.env:本进程还要用它们。
|
|
6
|
+
*/
|
|
7
|
+
export function agentEnvironment(source: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
|
|
8
|
+
return Object.fromEntries(
|
|
9
|
+
Object.entries(source).filter(([name]) => !/^(FEISHU_|LARK_|BRIDGE_|AGENT_LOFT_|IM_AGENT_|TELEGRAM_)/i.test(name)),
|
|
10
|
+
);
|
|
11
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// 命令目录:输入「/」时给用户看的那张表。
|
|
2
|
+
//
|
|
3
|
+
// 两种来源:
|
|
4
|
+
// - agent-loft 自己的命令(/new /status …),由 hub 拦下来执行,所有 agent 通用;
|
|
5
|
+
// - agent 原生命令(Claude 的 skills、/compact 之类),agent-loft 不认识,原样转给 agent。
|
|
6
|
+
// 顺序和隐藏按 agent 分开存在配置里(设置 → 命令),用户自己排。
|
|
7
|
+
|
|
8
|
+
/** agent-loft 内置命令。name 不带斜杠;args 里尖括号是必填、方括号是可选 */
|
|
9
|
+
export type BuiltinSpec = { name: string; args?: string; description: string; multiAgentOnly?: boolean };
|
|
10
|
+
|
|
11
|
+
export const BUILTIN_COMMANDS: BuiltinSpec[] = [
|
|
12
|
+
{ name: "new", args: "[模型]", description: "开一条新会话" },
|
|
13
|
+
{ name: "status", description: "查看当前会话、模型、目录、剩余额度" },
|
|
14
|
+
{ name: "stop", description: "中断当前执行" },
|
|
15
|
+
{ name: "model", args: "[名称]", description: "切换模型(不带参数弹选择)" },
|
|
16
|
+
{ name: "effort", args: "[程度]", description: "切换思考程度" },
|
|
17
|
+
{ name: "sessions", args: "[all]", description: "列出历史会话,点按钮恢复;加 all 列所有目录" },
|
|
18
|
+
{ name: "resume", args: "<会话ID>", description: "恢复指定会话" },
|
|
19
|
+
{ name: "fork", description: "从当前会话分叉,原会话保留" },
|
|
20
|
+
{ name: "agent", args: "[名称]", description: "查看 / 切换 agent", multiAgentOnly: true },
|
|
21
|
+
{ name: "claude", description: "切换到 Claude Code", multiAgentOnly: true },
|
|
22
|
+
{ name: "codex", description: "切换到 Codex", multiAgentOnly: true },
|
|
23
|
+
{ name: "cd", args: "<相对目录>", description: "切换工作目录(限 workspace 内)" },
|
|
24
|
+
{ name: "pwd", description: "显示当前工作目录" },
|
|
25
|
+
{ name: "autoedit", args: "[on|off]", description: "本对话自动允许改工作目录内的文件(Bash 仍询问)" },
|
|
26
|
+
{ name: "approve", args: "<请求ID> allow-once|allow-always|allow-session|deny", description: "审批兜底(卡片失效时用)" },
|
|
27
|
+
{ name: "help", description: "显示帮助" },
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
/** agent 自己报上来的命令 */
|
|
31
|
+
export type AgentCommand = { name: string; description: string; args?: string };
|
|
32
|
+
|
|
33
|
+
export type CommandEntry = {
|
|
34
|
+
name: string;
|
|
35
|
+
args?: string;
|
|
36
|
+
description: string;
|
|
37
|
+
source: "agent-loft" | "agent";
|
|
38
|
+
/** 不带参数就能直接执行(飞书卡片上给按钮、Web 里回车直接发) */
|
|
39
|
+
runnable: boolean;
|
|
40
|
+
hidden: boolean;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export type CommandPrefs = { order: string[]; hidden: string[] };
|
|
44
|
+
|
|
45
|
+
/** 参数里有必填项(尖括号)就不能一键执行 */
|
|
46
|
+
function needsArgs(args: string | undefined): boolean {
|
|
47
|
+
return !!args && args.includes("<");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 合并内置命令和 agent 原生命令,再按用户配置排序。
|
|
52
|
+
* - 只对接得上的 agent 才列 /claude /codex 这类快捷切换;
|
|
53
|
+
* - 和内置命令重名的原生命令丢掉:反正会被 agent-loft 先拦下来,列出来只会误导;
|
|
54
|
+
* - 配置里排过序的在前,按配置的顺序;没排过的(比如新装的 skill)按默认顺序接在后面。
|
|
55
|
+
*/
|
|
56
|
+
export function buildCatalog(params: {
|
|
57
|
+
allowedAgents: string[];
|
|
58
|
+
native: AgentCommand[];
|
|
59
|
+
prefs: CommandPrefs | undefined;
|
|
60
|
+
}): CommandEntry[] {
|
|
61
|
+
const multi = params.allowedAgents.length > 1;
|
|
62
|
+
const builtin: CommandEntry[] = BUILTIN_COMMANDS.filter(
|
|
63
|
+
(c) =>
|
|
64
|
+
(!c.multiAgentOnly || multi) &&
|
|
65
|
+
// /claude /codex 只在这个渠道能用对应 agent 时才有意义
|
|
66
|
+
(!["claude", "codex"].includes(c.name) || params.allowedAgents.includes(c.name)),
|
|
67
|
+
).map((c) => ({
|
|
68
|
+
name: c.name,
|
|
69
|
+
...(c.args ? { args: c.args } : {}),
|
|
70
|
+
description: c.description,
|
|
71
|
+
source: "agent-loft" as const,
|
|
72
|
+
runnable: !needsArgs(c.args),
|
|
73
|
+
hidden: false,
|
|
74
|
+
}));
|
|
75
|
+
const taken = new Set(BUILTIN_COMMANDS.map((c) => c.name));
|
|
76
|
+
const seen = new Set<string>();
|
|
77
|
+
const native: CommandEntry[] = [];
|
|
78
|
+
for (const c of params.native) {
|
|
79
|
+
const name = c.name.replace(/^\//, "");
|
|
80
|
+
if (!name || taken.has(name) || seen.has(name)) continue;
|
|
81
|
+
seen.add(name);
|
|
82
|
+
native.push({
|
|
83
|
+
name,
|
|
84
|
+
...(c.args ? { args: c.args } : {}),
|
|
85
|
+
description: c.description,
|
|
86
|
+
source: "agent",
|
|
87
|
+
runnable: !needsArgs(c.args),
|
|
88
|
+
hidden: false,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const all = [...builtin, ...native];
|
|
93
|
+
const order = params.prefs?.order ?? [];
|
|
94
|
+
const hidden = new Set(params.prefs?.hidden ?? []);
|
|
95
|
+
const rank = new Map(order.map((name, i) => [name, i]));
|
|
96
|
+
return all
|
|
97
|
+
.map((c, i) => ({ c, i }))
|
|
98
|
+
.sort((a, b) => {
|
|
99
|
+
const ra = rank.get(a.c.name);
|
|
100
|
+
const rb = rank.get(b.c.name);
|
|
101
|
+
if (ra !== undefined && rb !== undefined) return ra - rb;
|
|
102
|
+
if (ra !== undefined) return -1;
|
|
103
|
+
if (rb !== undefined) return 1;
|
|
104
|
+
return a.i - b.i;
|
|
105
|
+
})
|
|
106
|
+
.map(({ c }) => ({ ...c, hidden: hidden.has(c.name) }));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** 「/name args」,给人看的写法 */
|
|
110
|
+
export function usageOf(c: { name: string; args?: string }): string {
|
|
111
|
+
return `/${c.name}${c.args ? ` ${c.args}` : ""}`;
|
|
112
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// 斜杠命令解析(各渠道通用)。
|
|
2
|
+
//
|
|
3
|
+
// 只有「整条消息就是一个命令」时才当命令处理,参数对不上就退化成普通聊天 ——
|
|
4
|
+
// 避免把正常对话误当指令执行。
|
|
5
|
+
import { BUILTIN_COMMANDS, usageOf } from "./command-catalog.js";
|
|
6
|
+
import type { ApprovalDecision } from "./types.js";
|
|
7
|
+
|
|
8
|
+
export type ParsedCommand =
|
|
9
|
+
| { kind: "new"; model?: string }
|
|
10
|
+
| { kind: "fork" }
|
|
11
|
+
| { kind: "resume"; sessionId: string }
|
|
12
|
+
| { kind: "sessions"; all: boolean }
|
|
13
|
+
| { kind: "model"; model?: string }
|
|
14
|
+
| { kind: "effort"; effort?: string }
|
|
15
|
+
| { kind: "agent"; agent?: string }
|
|
16
|
+
| { kind: "stop" }
|
|
17
|
+
| { kind: "cd"; dir: string }
|
|
18
|
+
| { kind: "status" }
|
|
19
|
+
| { kind: "pwd" }
|
|
20
|
+
| { kind: "autoedit"; on?: boolean }
|
|
21
|
+
| { kind: "help" }
|
|
22
|
+
/** 只发了一个「/」:列出可用命令 */
|
|
23
|
+
| { kind: "commands" }
|
|
24
|
+
| { kind: "approve"; requestId: string; decision: ApprovalDecision }
|
|
25
|
+
| { kind: "chat"; text: string };
|
|
26
|
+
|
|
27
|
+
const DECISIONS: ApprovalDecision[] = ["allow-once", "allow-always", "allow-session", "deny"];
|
|
28
|
+
|
|
29
|
+
export function parseCommand(raw: string): ParsedCommand {
|
|
30
|
+
const text = raw.trim();
|
|
31
|
+
// 全角斜杠也认:中文输入法下很容易打成「/」
|
|
32
|
+
if (text === "/" || text === "/") return { kind: "commands" };
|
|
33
|
+
if (!text.startsWith("/") || text.includes("\n")) return { kind: "chat", text };
|
|
34
|
+
|
|
35
|
+
const [head = "", ...rest] = text.slice(1).split(/\s+/);
|
|
36
|
+
const arg = rest.join(" ").trim();
|
|
37
|
+
|
|
38
|
+
switch (head.toLowerCase()) {
|
|
39
|
+
case "new":
|
|
40
|
+
return { kind: "new", ...(arg ? { model: arg } : {}) };
|
|
41
|
+
case "fork":
|
|
42
|
+
return { kind: "fork" };
|
|
43
|
+
case "resume":
|
|
44
|
+
return arg ? { kind: "resume", sessionId: arg } : { kind: "chat", text };
|
|
45
|
+
case "sessions":
|
|
46
|
+
return { kind: "sessions", all: arg === "all" || arg === "-a" };
|
|
47
|
+
case "model":
|
|
48
|
+
return { kind: "model", ...(arg ? { model: arg } : {}) };
|
|
49
|
+
case "effort":
|
|
50
|
+
return { kind: "effort", ...(arg ? { effort: arg } : {}) };
|
|
51
|
+
case "agent":
|
|
52
|
+
return { kind: "agent", ...(arg ? { agent: arg.toLowerCase() } : {}) };
|
|
53
|
+
// 快捷切换
|
|
54
|
+
case "claude":
|
|
55
|
+
case "codex":
|
|
56
|
+
return arg ? { kind: "chat", text } : { kind: "agent", agent: head.toLowerCase() };
|
|
57
|
+
case "stop":
|
|
58
|
+
return { kind: "stop" };
|
|
59
|
+
case "cd":
|
|
60
|
+
return arg ? { kind: "cd", dir: arg } : { kind: "chat", text };
|
|
61
|
+
case "status":
|
|
62
|
+
return { kind: "status" };
|
|
63
|
+
case "pwd":
|
|
64
|
+
return { kind: "pwd" };
|
|
65
|
+
case "autoedit": {
|
|
66
|
+
const v = arg.toLowerCase();
|
|
67
|
+
if (!v) return { kind: "autoedit" };
|
|
68
|
+
if (["on", "开", "1", "true"].includes(v)) return { kind: "autoedit", on: true };
|
|
69
|
+
if (["off", "关", "0", "false"].includes(v)) return { kind: "autoedit", on: false };
|
|
70
|
+
return { kind: "chat", text };
|
|
71
|
+
}
|
|
72
|
+
case "help":
|
|
73
|
+
return { kind: "help" };
|
|
74
|
+
case "approve": {
|
|
75
|
+
const [requestId, decision] = rest;
|
|
76
|
+
if (!requestId || !decision) return { kind: "chat", text };
|
|
77
|
+
const normalized = decision.toLowerCase() as ApprovalDecision;
|
|
78
|
+
if (!DECISIONS.includes(normalized)) return { kind: "chat", text };
|
|
79
|
+
return { kind: "approve", requestId, decision: normalized };
|
|
80
|
+
}
|
|
81
|
+
default:
|
|
82
|
+
// 不认识的命令原样交给 agent:Claude / Codex 自己也有斜杠命令
|
|
83
|
+
return { kind: "chat", text };
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function helpText(agents: string[]): string {
|
|
88
|
+
const multi = agents.length > 1;
|
|
89
|
+
return [
|
|
90
|
+
"直接说话即可,会自动接着上次的会话。闲置太久会自动开新会话。",
|
|
91
|
+
"",
|
|
92
|
+
"Agent 给选项时会发一张提问卡,点按钮作答;都不合适就直接发消息,那条消息会被当成你的回答。",
|
|
93
|
+
"",
|
|
94
|
+
"**命令**(需整条消息只有命令;只发一个 `/` 可以列出全部命令,包括 agent 自己的)",
|
|
95
|
+
"",
|
|
96
|
+
...BUILTIN_COMMANDS.filter((c) => !c.multiAgentOnly || multi)
|
|
97
|
+
.filter((c) => !["claude", "codex"].includes(c.name))
|
|
98
|
+
.map((c) =>
|
|
99
|
+
c.name === "agent"
|
|
100
|
+
? `\`${usageOf(c)}\` ${c.description}(可选:${agents.join("、")}),也可以直接发 ${agents.map((a) => `\`/${a}\``).join(" ")}`
|
|
101
|
+
: `\`${usageOf(c)}\` ${c.description}`,
|
|
102
|
+
),
|
|
103
|
+
].join("\n");
|
|
104
|
+
}
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
// 配置:一个 JSON 文件,由 Web 设置页读写,也可以手改。
|
|
2
|
+
//
|
|
3
|
+
// 和旧桥接器的 .env 不同,这里的配置在运行中会变(Web 上改完立即生效),
|
|
4
|
+
// 所以对外是「读当前值 + 订阅变化」,而不是一个启动时冻结的常量。
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import crypto from "node:crypto";
|
|
8
|
+
import { configPath, ensureDirs, readJson, writeJsonAtomic } from "../home.js";
|
|
9
|
+
import type { CommandPrefs } from "./command-catalog.js";
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
export type SandboxMode = "read-only" | "workspace-write" | "danger-full-access";
|
|
13
|
+
|
|
14
|
+
export type FeishuBotConfig = {
|
|
15
|
+
/** 内部 id,会话键里会用到,创建后不要改 */
|
|
16
|
+
id: string;
|
|
17
|
+
name: string;
|
|
18
|
+
enabled: boolean;
|
|
19
|
+
appId: string;
|
|
20
|
+
appSecret: string;
|
|
21
|
+
domain: "feishu" | "lark";
|
|
22
|
+
/** owner 白名单(open_id)。配对批准的人另存在 data/pairing-<id>.json */
|
|
23
|
+
allowFrom: string[];
|
|
24
|
+
/** 这个机器人能用哪些 agent。只有一个就是「一个机器人一个 agent」 */
|
|
25
|
+
agents: string[];
|
|
26
|
+
/** 新会话默认用哪个 agent,必须在 agents 里 */
|
|
27
|
+
defaultAgent: string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type AppConfig = {
|
|
31
|
+
/** 所有会话的默认工作目录;/cd 只能在它之内 */
|
|
32
|
+
workspaceRoot: string;
|
|
33
|
+
approvalTimeoutMs: number;
|
|
34
|
+
sessionTtlMs: number;
|
|
35
|
+
agents: {
|
|
36
|
+
claude: {
|
|
37
|
+
enabled: boolean;
|
|
38
|
+
defaultModel: string;
|
|
39
|
+
defaultEffort: string;
|
|
40
|
+
/** 输入框模型列表里显示哪些(模型 id),空 = 全部显示 */
|
|
41
|
+
models: string[];
|
|
42
|
+
};
|
|
43
|
+
codex: {
|
|
44
|
+
enabled: boolean;
|
|
45
|
+
/** codex 可执行文件,systemd 下 PATH 很短,最好写绝对路径 */
|
|
46
|
+
path: string;
|
|
47
|
+
/** 留空用 ~/.codex */
|
|
48
|
+
home: string;
|
|
49
|
+
sandbox: SandboxMode;
|
|
50
|
+
defaultModel: string;
|
|
51
|
+
defaultEffort: string;
|
|
52
|
+
/** 同 claude.models */
|
|
53
|
+
models: string[];
|
|
54
|
+
};
|
|
55
|
+
};
|
|
56
|
+
web: {
|
|
57
|
+
enabled: boolean;
|
|
58
|
+
host: string;
|
|
59
|
+
port: number;
|
|
60
|
+
/** scrypt 哈希,空 = 还没设密码(首次访问需要终端里打印的设置码) */
|
|
61
|
+
passwordHash: string;
|
|
62
|
+
/**
|
|
63
|
+
* 可信反向代理的 IP。只有从这些地址来的连接,才采信 X-Forwarded-* 头 ——
|
|
64
|
+
* 否则直连的人随手伪造一个 X-Forwarded-For 就能绕过登录限流。
|
|
65
|
+
* 反代(比如 Lucky)不在本机时,把它的 IP 加进来。
|
|
66
|
+
*/
|
|
67
|
+
trustedProxies: string[];
|
|
68
|
+
/** 侧栏额度空闲时多久自动刷新一次(分钟),0 = 不自动刷新 */
|
|
69
|
+
quotaRefreshMin: number;
|
|
70
|
+
};
|
|
71
|
+
/** 输入「/」时命令列表的顺序和隐藏项,按 agent 分开(设置 → 命令) */
|
|
72
|
+
commands: { claude: CommandPrefs; codex: CommandPrefs };
|
|
73
|
+
feishu: {
|
|
74
|
+
/** 长连接掉线多久就重建,0 关闭 */
|
|
75
|
+
watchdogMs: number;
|
|
76
|
+
bots: FeishuBotConfig[];
|
|
77
|
+
};
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export const AGENT_IDS = ["claude", "codex"] as const;
|
|
81
|
+
|
|
82
|
+
export function defaultConfig(): AppConfig {
|
|
83
|
+
return {
|
|
84
|
+
workspaceRoot: process.env.HOME ? path.join(process.env.HOME, "project") : process.cwd(),
|
|
85
|
+
approvalTimeoutMs: 30 * 60 * 1000,
|
|
86
|
+
sessionTtlMs: 12 * 60 * 60 * 1000,
|
|
87
|
+
agents: {
|
|
88
|
+
claude: { enabled: true, defaultModel: "", defaultEffort: "", models: [] },
|
|
89
|
+
codex: {
|
|
90
|
+
enabled: true,
|
|
91
|
+
path: "codex",
|
|
92
|
+
home: "",
|
|
93
|
+
sandbox: "workspace-write",
|
|
94
|
+
defaultModel: "",
|
|
95
|
+
defaultEffort: "",
|
|
96
|
+
models: [],
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
web: { enabled: true, host: "0.0.0.0", port: 8800, passwordHash: "", trustedProxies: ["127.0.0.1", "::1"], quotaRefreshMin: 10 },
|
|
100
|
+
commands: { claude: { order: [], hidden: [] }, codex: { order: [], hidden: [] } },
|
|
101
|
+
feishu: { watchdogMs: 10 * 60 * 1000, bots: [] },
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** 递归补齐缺省字段:老配置文件缺新字段时不至于整个崩掉。 */
|
|
106
|
+
function mergeDefaults<T>(base: T, value: unknown): T {
|
|
107
|
+
if (Array.isArray(base)) return (Array.isArray(value) ? value : base) as T;
|
|
108
|
+
if (typeof base !== "object" || base === null) {
|
|
109
|
+
return (value === undefined || value === null || typeof value !== typeof base ? base : value) as T;
|
|
110
|
+
}
|
|
111
|
+
const out: Record<string, unknown> = { ...(base as Record<string, unknown>) };
|
|
112
|
+
const v = (typeof value === "object" && value !== null ? value : {}) as Record<string, unknown>;
|
|
113
|
+
for (const key of Object.keys(out)) out[key] = mergeDefaults(out[key], v[key]);
|
|
114
|
+
return out as T;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function normalizeBot(raw: Partial<FeishuBotConfig>): FeishuBotConfig {
|
|
118
|
+
const agents = (raw.agents ?? []).filter((a) => (AGENT_IDS as readonly string[]).includes(a));
|
|
119
|
+
const list = agents.length > 0 ? agents : ["claude"];
|
|
120
|
+
return {
|
|
121
|
+
id: raw.id?.trim() || `bot_${crypto.randomBytes(3).toString("hex")}`,
|
|
122
|
+
name: raw.name?.trim() || "飞书机器人",
|
|
123
|
+
enabled: raw.enabled !== false,
|
|
124
|
+
appId: raw.appId?.trim() ?? "",
|
|
125
|
+
appSecret: raw.appSecret?.trim() ?? "",
|
|
126
|
+
domain: raw.domain === "lark" ? "lark" : "feishu",
|
|
127
|
+
allowFrom: (raw.allowFrom ?? [])
|
|
128
|
+
.map((s) => s.trim().toLowerCase())
|
|
129
|
+
.filter((s) => s.startsWith("ou_")),
|
|
130
|
+
agents: list,
|
|
131
|
+
defaultAgent: raw.defaultAgent && list.includes(raw.defaultAgent) ? raw.defaultAgent : list[0]!,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
let current: AppConfig | null = null;
|
|
136
|
+
const listeners = new Set<(next: AppConfig, prev: AppConfig) => void>();
|
|
137
|
+
|
|
138
|
+
export function loadConfig(): AppConfig {
|
|
139
|
+
if (current) return current;
|
|
140
|
+
ensureDirs();
|
|
141
|
+
const raw = readJson<unknown>(configPath, null);
|
|
142
|
+
const merged = mergeDefaults(defaultConfig(), raw);
|
|
143
|
+
merged.workspaceRoot = path.resolve(merged.workspaceRoot);
|
|
144
|
+
merged.feishu.bots = merged.feishu.bots.map(normalizeBot);
|
|
145
|
+
current = merged;
|
|
146
|
+
if (!raw) writeJsonAtomic(configPath, merged);
|
|
147
|
+
watchExternalEdits();
|
|
148
|
+
return current;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* 配置文件被别的进程改了(手改、或终端里跑了 agent-loft passwd):重新读进来。
|
|
153
|
+
* 自己保存触发的变更内容一致,比较后直接跳过。
|
|
154
|
+
*/
|
|
155
|
+
let watching = false;
|
|
156
|
+
function watchExternalEdits(): void {
|
|
157
|
+
if (watching) return;
|
|
158
|
+
watching = true;
|
|
159
|
+
let timer: NodeJS.Timeout | null = null;
|
|
160
|
+
try {
|
|
161
|
+
fs.watch(path.dirname(configPath), { persistent: false }, (_event, file) => {
|
|
162
|
+
if (file !== path.basename(configPath)) return;
|
|
163
|
+
if (timer) clearTimeout(timer);
|
|
164
|
+
timer = setTimeout(() => {
|
|
165
|
+
const raw = readJson<unknown>(configPath, null);
|
|
166
|
+
if (!raw || !current) return;
|
|
167
|
+
const next = mergeDefaults(defaultConfig(), raw);
|
|
168
|
+
next.workspaceRoot = path.resolve(next.workspaceRoot);
|
|
169
|
+
next.feishu.bots = next.feishu.bots.map(normalizeBot);
|
|
170
|
+
if (JSON.stringify(next) === JSON.stringify(current)) return;
|
|
171
|
+
const prev = current;
|
|
172
|
+
current = next;
|
|
173
|
+
console.log("[配置] 检测到配置文件被修改,已重新加载");
|
|
174
|
+
for (const listener of listeners) {
|
|
175
|
+
try {
|
|
176
|
+
listener(next, prev);
|
|
177
|
+
} catch (err) {
|
|
178
|
+
console.error("[配置] 变更回调出错:", err);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}, 200);
|
|
182
|
+
});
|
|
183
|
+
} catch {
|
|
184
|
+
// 监听不了就算了,重启后生效
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** 当前配置。别缓存返回值里的子对象:保存后整个对象会被换掉。 */
|
|
189
|
+
export function config(): AppConfig {
|
|
190
|
+
return current ?? loadConfig();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function saveConfig(next: AppConfig): AppConfig {
|
|
194
|
+
const prev = config();
|
|
195
|
+
const normalized = mergeDefaults(defaultConfig(), next);
|
|
196
|
+
normalized.workspaceRoot = path.resolve(normalized.workspaceRoot);
|
|
197
|
+
normalized.feishu.bots = normalized.feishu.bots.map(normalizeBot);
|
|
198
|
+
writeJsonAtomic(configPath, normalized);
|
|
199
|
+
current = normalized;
|
|
200
|
+
for (const listener of listeners) {
|
|
201
|
+
try {
|
|
202
|
+
listener(normalized, prev);
|
|
203
|
+
} catch (err) {
|
|
204
|
+
console.error("[配置] 变更回调出错:", err);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return normalized;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function onConfigChange(listener: (next: AppConfig, prev: AppConfig) => void): () => void {
|
|
211
|
+
listeners.add(listener);
|
|
212
|
+
return () => listeners.delete(listener);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function inside(root: string, target: string): boolean {
|
|
216
|
+
const rel = path.relative(root, target);
|
|
217
|
+
return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* 把用户给的目录限制在 workspaceRoot 之内,防止 /cd 跳到任意路径。
|
|
222
|
+
*
|
|
223
|
+
* 必须比较真实路径:工作区里一个指向外面的符号链接,字符串上在里面,实际在外面。
|
|
224
|
+
* 目标还必须存在且是目录 —— 不存在的目录拉不起 agent。
|
|
225
|
+
*/
|
|
226
|
+
export function resolveWorkspacePath(input: string, base?: string): string | null {
|
|
227
|
+
const root = config().workspaceRoot;
|
|
228
|
+
const target = path.resolve(base ?? root, input);
|
|
229
|
+
if (!inside(root, target)) return null;
|
|
230
|
+
try {
|
|
231
|
+
const realRoot = fs.realpathSync(root);
|
|
232
|
+
const realTarget = fs.realpathSync(target);
|
|
233
|
+
if (!inside(realRoot, realTarget) || !fs.statSync(realTarget).isDirectory()) return null;
|
|
234
|
+
// 用工作区根目录的写法返回:根目录本身是符号链接时,存下来的路径仍然落在根目录之内
|
|
235
|
+
return path.join(root, path.relative(realRoot, realTarget));
|
|
236
|
+
} catch {
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export type WorkspaceDirListing = {
|
|
242
|
+
root: string;
|
|
243
|
+
/** 绝对路径(工作区根目录的写法) */
|
|
244
|
+
path: string;
|
|
245
|
+
/** 相对工作区根目录,根目录本身是 "" */
|
|
246
|
+
rel: string;
|
|
247
|
+
dirs: { name: string; hidden: boolean }[];
|
|
248
|
+
/** 子目录太多,只列了前一部分 */
|
|
249
|
+
truncated: boolean;
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
const MAX_LISTED_DIRS = 500;
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* 列出工作区内某个目录下的子目录,给 Web 的目录选择器用。只列目录不列文件;
|
|
256
|
+
* 指向工作区外面(或已经失效)的符号链接不列 —— 列出来也 /cd 不进去。
|
|
257
|
+
*/
|
|
258
|
+
export function listWorkspaceDirs(input: string): WorkspaceDirListing | null {
|
|
259
|
+
const root = config().workspaceRoot;
|
|
260
|
+
const resolved = resolveWorkspacePath(input || ".");
|
|
261
|
+
if (!resolved) return null;
|
|
262
|
+
let entries: fs.Dirent[];
|
|
263
|
+
try {
|
|
264
|
+
entries = fs.readdirSync(resolved, { withFileTypes: true });
|
|
265
|
+
} catch {
|
|
266
|
+
return null;
|
|
267
|
+
}
|
|
268
|
+
const dirs: { name: string; hidden: boolean }[] = [];
|
|
269
|
+
for (const entry of entries) {
|
|
270
|
+
const ok = entry.isDirectory() || (entry.isSymbolicLink() && resolveWorkspacePath(path.join(resolved, entry.name)) !== null);
|
|
271
|
+
if (ok) dirs.push({ name: entry.name, hidden: entry.name.startsWith(".") });
|
|
272
|
+
}
|
|
273
|
+
dirs.sort((a, b) => Number(a.hidden) - Number(b.hidden) || a.name.localeCompare(b.name));
|
|
274
|
+
return {
|
|
275
|
+
root,
|
|
276
|
+
path: resolved,
|
|
277
|
+
rel: path.relative(root, resolved),
|
|
278
|
+
dirs: dirs.slice(0, MAX_LISTED_DIRS),
|
|
279
|
+
truncated: dirs.length > MAX_LISTED_DIRS,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export function isInsideWorkspace(dir: string): boolean {
|
|
284
|
+
return resolveWorkspacePath(dir) === path.resolve(dir);
|
|
285
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// 事件去重。IM 平台对消息和回调都可能重投,不挡住就会重复执行:
|
|
2
|
+
// 消息重投 = 同一句话跑两遍;回调重投 = 第一次消费掉待处理项,第二次误报「已失效」。
|
|
3
|
+
export class Dedup {
|
|
4
|
+
private seen = new Set<string>();
|
|
5
|
+
constructor(private readonly max = 2000) {}
|
|
6
|
+
|
|
7
|
+
isDuplicate(id: string | undefined): boolean {
|
|
8
|
+
if (!id) return false;
|
|
9
|
+
if (this.seen.has(id)) return true;
|
|
10
|
+
this.seen.add(id);
|
|
11
|
+
if (this.seen.size > this.max) {
|
|
12
|
+
for (const key of [...this.seen].slice(0, this.max / 2)) this.seen.delete(key);
|
|
13
|
+
}
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
}
|