agent-loft 0.0.0-stage → 0.2.7

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,213 @@
1
+ // 核心类型:渠道(飞书 / Web / 以后的 Telegram)与 agent(Claude / Codex / 以后的 ACP)
2
+ // 之间只通过这里的类型说话。渠道不认识 Claude,agent 不认识飞书。
3
+ import type { AgentCommand } from "./command-catalog.js";
4
+
5
+ // ---------- 用户输入 ----------
6
+
7
+ export type AttachmentKind = "image" | "file" | "audio" | "video";
8
+
9
+ /** 已经落盘的附件。各渠道负责下载 / 接收,agent 负责决定怎么喂给模型。 */
10
+ export type SavedAttachment = {
11
+ kind: AttachmentKind;
12
+ path: string;
13
+ name: string;
14
+ size: number;
15
+ mediaType?: string;
16
+ durationMs?: number;
17
+ };
18
+
19
+ export type UserInput = { text: string; attachments: SavedAttachment[] };
20
+
21
+ // ---------- 交互(审批 / 提问) ----------
22
+
23
+ /**
24
+ * allow-session:本会话内后续的审批全部自动放行(不落盘)。会话重建
25
+ * (/new、切目录、切 agent、恢复历史、进程重启)后失效。
26
+ */
27
+ export type ApprovalDecision = "allow-once" | "allow-always" | "allow-session" | "deny";
28
+
29
+ /** 一个卡点的收束方式。timeout / cancelled 和 deny 一样都是「没放行」,但显示不同。 */
30
+ export type Settlement = ApprovalDecision | "timeout" | "cancelled" | "answered" | "skipped";
31
+
32
+ export type QuestionSpec = {
33
+ /** 回填答案时用的键:Claude 用问题原文,Codex 用问题 id */
34
+ id: string;
35
+ header: string;
36
+ question: string;
37
+ options: { label: string; description?: string }[];
38
+ multiSelect: boolean;
39
+ /** 允许自由输入(直接发一条消息作答) */
40
+ allowOther: boolean;
41
+ };
42
+
43
+ export type InteractionSpec =
44
+ | {
45
+ kind: "approval";
46
+ title: string;
47
+ /** markdown */
48
+ detail: string;
49
+ /** 「总是允许」按钮的文字;不给就没有这个按钮 */
50
+ alwaysLabel?: string;
51
+ /** 给了就显示「本会话全部允许」按钮。只有 agent 能真正兑现时才给 */
52
+ allowSession?: boolean;
53
+ }
54
+ | { kind: "question"; title: string; questions: QuestionSpec[] };
55
+
56
+ export type InteractionOutcome = {
57
+ settlement: Settlement;
58
+ /** 仅提问:问题 id → 选中的答案(多选为多个) */
59
+ answers: Record<string, string[]>;
60
+ };
61
+
62
+ /** agent 向用户发起交互的入口,由核心实现。返回值是用户的决定。 */
63
+ export type InteractionHost = (spec: InteractionSpec) => Promise<InteractionOutcome>;
64
+
65
+ // ---------- agent 产生的事件 ----------
66
+
67
+ export type StepKind = "thinking" | "tool" | "note" | "warning";
68
+
69
+ export type TurnResult = {
70
+ text: string;
71
+ durationMs: number;
72
+ costUsd?: number;
73
+ tokens?: number;
74
+ /** 这条回复在 agent 记录里的位置,从这里分叉用(见 AgentDriver.forkAt) */
75
+ anchor?: string;
76
+ };
77
+
78
+ export type AgentEvents = {
79
+ /** 一轮开始(agent 真正开始处理) */
80
+ onTurnStart?: () => void;
81
+ /** 过程信息:思考、工具调用等,一行摘要 */
82
+ onStep: (kind: StepKind, text: string) => void;
83
+ /** 回复正文的流式片段(可选能力,飞书不用,Web 用来逐字显示) */
84
+ onTextDelta: (delta: string) => void;
85
+ /** 一段完整的回复正文 */
86
+ onText: (text: string) => void;
87
+ onResult: (result: TurnResult) => void;
88
+ onError: (err: unknown) => void;
89
+ /** 拿到 / 更新了 agent 侧的会话 id,持久化后用于 resume */
90
+ onSessionId: (id: string) => void;
91
+ /** 有 n 条排队中的输入被丢弃了(不会再有结果):调用方据此结清「进行中」的计数 */
92
+ onDropped?: (n: number) => void;
93
+ /** 上下文窗口占用更新了(一般在每轮结束后) */
94
+ onContext?: (ctx: ContextUsage) => void;
95
+ };
96
+
97
+ /** 上下文窗口占用:used / window 都是 token 数,percent 是 0-100(可能略超 100) */
98
+ export type ContextUsage = { used: number; window: number; percent: number };
99
+
100
+ /** 账号额度(订阅的用量窗口)。没有订阅额度(API key 之类)时 driver 返回 null */
101
+ export type QuotaInfo = {
102
+ plan?: string;
103
+ windows: {
104
+ /** 完整名称,如「5 小时」「7 天(Opus)」 */
105
+ label: string;
106
+ /** 紧凑显示用,如「5h」「7d」 */
107
+ short: string;
108
+ percent: number;
109
+ /** 重置时刻(毫秒时间戳) */
110
+ resetsAt?: number;
111
+ }[];
112
+ /** 额度包等附加说明 */
113
+ extra?: string;
114
+ /** 已撞上限 */
115
+ reached?: boolean;
116
+ };
117
+
118
+ // ---------- agent ----------
119
+
120
+ export type RuntimeInfo = { model?: string; effort?: string };
121
+
122
+ export type ModelInfo = {
123
+ id: string;
124
+ label: string;
125
+ description?: string;
126
+ efforts?: string[];
127
+ isDefault?: boolean;
128
+ };
129
+
130
+ export type SessionSummary = {
131
+ id: string;
132
+ title: string;
133
+ cwd?: string;
134
+ updatedAt: number;
135
+ };
136
+
137
+ export type HistoryMessage = {
138
+ role: "user" | "assistant";
139
+ text: string;
140
+ /** 助手这一条之前的过程摘要 */
141
+ steps?: string[];
142
+ /** 毫秒时间戳;助手消息是这一轮结束的时间 */
143
+ ts?: number;
144
+ /** 助手消息:从这条回复处分叉用的位置(见 AgentDriver.forkAt) */
145
+ anchor?: string;
146
+ /** 助手消息:这一轮的用时 / 费用 / tokens(agent-loft 自己记的,agent 记录里没有) */
147
+ footer?: string;
148
+ };
149
+
150
+ export type SessionOptions = {
151
+ cwd: string;
152
+ model?: string;
153
+ effort?: string;
154
+ resumeId?: string;
155
+ fork?: boolean;
156
+ /** 附件所在目录,agent 需要能直接读 */
157
+ uploadsDir: string;
158
+ /** 工作目录内改文件不再询问(/autoedit)。Bash 等照常询问 */
159
+ autoEdit?: boolean;
160
+ };
161
+
162
+ export interface AgentSession {
163
+ readonly sessionId: string | undefined;
164
+ send(input: UserInput): void;
165
+ interrupt(): Promise<void>;
166
+ setModel(model?: string, effort?: string): Promise<void>;
167
+ describeRuntime(): Promise<RuntimeInfo>;
168
+ /** 手上已知的模型 / 思考程度,同步返回:不拉起进程、不发请求 */
169
+ peekRuntime?(): RuntimeInfo;
170
+ dispose(): Promise<void>;
171
+ /** 运行中切换「自动允许改文件」;不支持的 agent 不实现 */
172
+ setAutoEdit?(on: boolean): Promise<void>;
173
+ }
174
+
175
+ export type Diagnosis = {
176
+ /** 发给用户看的一句人话 */
177
+ chatMessage: string;
178
+ /** 打在日志里的修复步骤 */
179
+ consoleHint: string;
180
+ kind: string;
181
+ };
182
+
183
+ export interface AgentDriver {
184
+ readonly id: string;
185
+ readonly label: string;
186
+ /** 能力声明:前端据此决定显示哪些控件 */
187
+ readonly capabilities: { effort: boolean; fork: boolean; cost: boolean; streaming: boolean };
188
+ start(): Promise<void>;
189
+ stop(): Promise<void>;
190
+ createSession(opts: SessionOptions, events: AgentEvents, host: InteractionHost): AgentSession;
191
+ /** cwd 为 undefined 时列全部目录 */
192
+ listSessions(cwd: string | undefined, limit?: number): Promise<SessionSummary[]>;
193
+ sessionExists(id: string, cwd: string): Promise<boolean>;
194
+ readHistory(id: string, cwd: string): Promise<HistoryMessage[]>;
195
+ /**
196
+ * 把会话复制出一条新的、返回新会话 id。anchor(HistoryMessage / TurnResult 里的)给了就只保留到
197
+ * 那条回复为止(含),不给就整条复制。原会话不受影响。
198
+ */
199
+ forkAt?(id: string, cwd: string, anchor?: string): Promise<string>;
200
+ /** 有的 agent(Claude)要借一个活着的会话才能问到模型列表 */
201
+ listModels(session: AgentSession): Promise<ModelInfo[]>;
202
+ /** 不依赖会话的模型列表,设置页的下拉框用 */
203
+ catalog(): Promise<ModelInfo[]>;
204
+ /** agent 自己的斜杠命令(skills 之类),没有就不实现。cwd 影响项目级命令 */
205
+ commands?(cwd?: string): Promise<AgentCommand[]>;
206
+ /** 不建会话也能知道的默认模型 / 思考程度 */
207
+ defaults(): Promise<RuntimeInfo>;
208
+ /** /status 里额外的几行(比如 Codex 的额度) */
209
+ statusLines(): Promise<string[]>;
210
+ /** 账号额度,侧栏显示用;读不到 / 没有订阅额度时返回 null */
211
+ quota?(): Promise<QuotaInfo | null>;
212
+ diagnose(err: unknown): Diagnosis;
213
+ }
@@ -0,0 +1,200 @@
1
+ // 附件落盘:每条会话一个上传目录,开新会话就整个删掉。
2
+ //
3
+ // 放在家目录下而不是项目目录里,免得弄脏用户的仓库。agent 需要能直接读这些文件:
4
+ // Claude 通过 additionalDirectories 放行,Codex 的沙箱本来就允许读。
5
+ import fs from "node:fs";
6
+ import path from "node:path";
7
+ import { dataDir } from "../home.js";
8
+ import type { AttachmentKind, SavedAttachment } from "./types.js";
9
+
10
+ const uploadsRoot = path.join(dataDir, "uploads");
11
+
12
+ export function uploadsDirFor(key: string): string {
13
+ return path.join(uploadsRoot, encodeURIComponent(key));
14
+ }
15
+
16
+ export function ensureUploadsDir(key: string): string {
17
+ const dir = uploadsDirFor(key);
18
+ fs.mkdirSync(dir, { recursive: true });
19
+ return dir;
20
+ }
21
+
22
+ /**
23
+ * keep:要保留的文件(绝对路径)。
24
+ * olderThan:只删修改时间早于它的(毫秒时间戳)—— 切换会话时用,保住切换途中刚收到的附件。
25
+ */
26
+ export function clearUploads(key: string, keep: string[] = [], olderThan?: number): void {
27
+ try {
28
+ const dir = uploadsDirFor(key);
29
+ if (keep.length === 0 && olderThan === undefined) {
30
+ fs.rmSync(dir, { recursive: true, force: true });
31
+ return;
32
+ }
33
+ const kept = new Set(keep.map((p) => path.resolve(p)));
34
+ for (const entry of fs.readdirSync(dir)) {
35
+ const full = path.join(dir, entry);
36
+ if (kept.has(full)) continue;
37
+ if (olderThan !== undefined && fs.statSync(full).mtimeMs >= olderThan) continue;
38
+ fs.rmSync(full, { recursive: true, force: true });
39
+ }
40
+ } catch (err) {
41
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return;
42
+ console.warn(`[附件] 清理 ${key} 的上传目录失败:`, err);
43
+ }
44
+ }
45
+
46
+ /** 启动时清理:会话没了、或闲置过期的,目录一并删掉。 */
47
+ export function sweepUploads(isAlive: (key: string) => boolean): void {
48
+ let entries: string[];
49
+ try {
50
+ entries = fs.readdirSync(uploadsRoot);
51
+ } catch {
52
+ return;
53
+ }
54
+ for (const entry of entries) {
55
+ let key: string;
56
+ try {
57
+ key = decodeURIComponent(entry);
58
+ } catch {
59
+ continue;
60
+ }
61
+ if (isAlive(key)) continue;
62
+ fs.rmSync(path.join(uploadsRoot, entry), { recursive: true, force: true });
63
+ }
64
+ }
65
+
66
+ /** 去掉路径分隔符和控制字符,防止文件名把文件写到目录外面去。 */
67
+ export function safeName(name: string): string {
68
+ const cleaned = path
69
+ .basename(name)
70
+ .replace(/[\u0000-\u001f/\\]/g, "_")
71
+ .trim();
72
+ return cleaned && cleaned !== "." && cleaned !== ".." ? cleaned.slice(0, 120) : "file";
73
+ }
74
+
75
+ /** 按文件头判断类型。飞书给的图片没有文件名,扩展名只能靠这个。 */
76
+ export function sniff(buf: Buffer): { mediaType: string; ext: string } | undefined {
77
+ const hex = buf.subarray(0, 12).toString("hex");
78
+ if (hex.startsWith("89504e47")) return { mediaType: "image/png", ext: "png" };
79
+ if (hex.startsWith("ffd8ff")) return { mediaType: "image/jpeg", ext: "jpg" };
80
+ if (hex.startsWith("47494638")) return { mediaType: "image/gif", ext: "gif" };
81
+ if (hex.startsWith("52494646") && buf.subarray(8, 12).toString("ascii") === "WEBP") {
82
+ return { mediaType: "image/webp", ext: "webp" };
83
+ }
84
+ if (hex.startsWith("25504446")) return { mediaType: "application/pdf", ext: "pdf" };
85
+ if (buf.subarray(0, 4).toString("ascii") === "OggS") return { mediaType: "audio/ogg", ext: "opus" };
86
+ if (buf.subarray(4, 8).toString("ascii") === "ftyp") return { mediaType: "video/mp4", ext: "mp4" };
87
+ return undefined;
88
+ }
89
+
90
+ /** 同名文件不覆盖,加序号。 */
91
+ export function uniquePath(dir: string, name: string): string {
92
+ let candidate = path.join(dir, name);
93
+ const ext = path.extname(name);
94
+ const stem = name.slice(0, name.length - ext.length);
95
+ for (let i = 2; fs.existsSync(candidate); i++) candidate = path.join(dir, `${stem}-${i}${ext}`);
96
+ return candidate;
97
+ }
98
+
99
+ function kindOf(mediaType: string | undefined): AttachmentKind {
100
+ if (mediaType?.startsWith("image/")) return "image";
101
+ if (mediaType?.startsWith("audio/")) return "audio";
102
+ if (mediaType?.startsWith("video/")) return "video";
103
+ return "file";
104
+ }
105
+
106
+ /**
107
+ * 把一段已在内存 / 临时文件里的内容存成附件。
108
+ * kind 不给就按文件头 / 声明的类型猜。
109
+ */
110
+ export function saveBuffer(params: {
111
+ key: string;
112
+ data: Buffer;
113
+ name?: string;
114
+ kind?: AttachmentKind;
115
+ mediaType?: string;
116
+ durationMs?: number;
117
+ /** 存进上传目录下的这个子目录(Web 用上传 id:每次上传路径唯一,重置后旧 id 不会撞上同名新文件) */
118
+ subdir?: string;
119
+ }): SavedAttachment {
120
+ const dir = params.subdir
121
+ ? path.join(ensureUploadsDir(params.key), safeName(params.subdir))
122
+ : ensureUploadsDir(params.key);
123
+ fs.mkdirSync(dir, { recursive: true });
124
+ const sniffed = sniff(params.data);
125
+ const mediaType = sniffed?.mediaType ?? params.mediaType;
126
+ const kind = params.kind ?? kindOf(mediaType);
127
+ const stamp = new Date().toISOString().replace(/[-:]/g, "").replace(/\..+/, "");
128
+ const name = params.name ? safeName(params.name) : `${kind}-${stamp}${sniffed ? `.${sniffed.ext}` : ""}`;
129
+ const target = uniquePath(dir, name);
130
+ fs.writeFileSync(target, params.data);
131
+ return {
132
+ kind,
133
+ path: target,
134
+ name: path.basename(target),
135
+ size: params.data.length,
136
+ ...(mediaType ? { mediaType } : {}),
137
+ ...(params.durationMs ? { durationMs: params.durationMs } : {}),
138
+ };
139
+ }
140
+
141
+ /** 已经下载到临时文件的:探测类型后挪进上传目录。 */
142
+ export function adoptFile(params: {
143
+ key: string;
144
+ tmpPath: string;
145
+ name?: string;
146
+ kind: AttachmentKind;
147
+ durationMs?: number;
148
+ }): SavedAttachment {
149
+ const dir = ensureUploadsDir(params.key);
150
+ const head = Buffer.alloc(12);
151
+ const fd = fs.openSync(params.tmpPath, "r");
152
+ fs.readSync(fd, head, 0, 12, 0);
153
+ fs.closeSync(fd);
154
+ const sniffed = sniff(head);
155
+ const stamp = new Date().toISOString().replace(/[-:]/g, "").replace(/\..+/, "");
156
+ const name = params.name
157
+ ? safeName(params.name)
158
+ : `${params.kind}-${stamp}${sniffed ? `.${sniffed.ext}` : ""}`;
159
+ const target = uniquePath(dir, name);
160
+ fs.renameSync(params.tmpPath, target);
161
+ return {
162
+ kind: params.kind,
163
+ path: target,
164
+ name: path.basename(target),
165
+ size: fs.statSync(target).size,
166
+ ...(sniffed ? { mediaType: sniffed.mediaType } : {}),
167
+ ...(params.durationMs ? { durationMs: params.durationMs } : {}),
168
+ };
169
+ }
170
+
171
+ export const KIND_LABEL: Record<AttachmentKind, string> = {
172
+ image: "图片",
173
+ file: "文件",
174
+ audio: "语音",
175
+ video: "视频",
176
+ };
177
+
178
+ export function formatSize(bytes: number): string {
179
+ if (bytes < 1024) return `${bytes}B`;
180
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
181
+ return `${(bytes / 1024 / 1024).toFixed(1)}MB`;
182
+ }
183
+
184
+ /** 附件清单的文字说明,两个 agent 都用它告诉模型文件在哪。 */
185
+ export function describeAttachments(
186
+ saved: SavedAttachment[],
187
+ inlined: (a: SavedAttachment) => boolean,
188
+ ): string {
189
+ const counters: Partial<Record<AttachmentKind, number>> = {};
190
+ const lines = saved.map((a) => {
191
+ const n = (counters[a.kind] = (counters[a.kind] ?? 0) + 1);
192
+ const details = [
193
+ formatSize(a.size),
194
+ ...(a.durationMs ? [`时长 ${Math.round(a.durationMs / 1000)} 秒`] : []),
195
+ ...(inlined(a) ? ["内容已附在本消息中"] : []),
196
+ ];
197
+ return `- ${KIND_LABEL[a.kind]}${n}:${a.path}(${details.join(",")})`;
198
+ });
199
+ return ["[用户发来了以下附件,已保存到本地,需要时可以直接读取或处理这些文件]", ...lines].join("\n");
200
+ }
package/src/home.ts ADDED
@@ -0,0 +1,43 @@
1
+ // 家目录定位。单独成模块:CLI 里有些命令在配置存在之前就要跑,
2
+ // 它们只需要知道目录在哪,不该被配置校验挡住。
3
+ import fs from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+
7
+ /**
8
+ * 配置与状态的家目录。AGENT_LOFT_HOME 可覆盖,默认 ~/.agent-loft。
9
+ * 兼容改名前(im-agent):旧的 IM_AGENT_HOME 也认;新目录还不存在而 ~/.im-agent 在,就继续用旧目录,
10
+ * 不自动搬家 —— 正在跑的旧服务还在写它。
11
+ */
12
+ export const home = (() => {
13
+ const explicit = (process.env.AGENT_LOFT_HOME ?? process.env.IM_AGENT_HOME)?.trim();
14
+ if (explicit) return path.resolve(explicit);
15
+ const current = path.join(os.homedir(), ".agent-loft");
16
+ const legacy = path.join(os.homedir(), ".im-agent");
17
+ if (!fs.existsSync(current) && fs.existsSync(legacy)) return legacy;
18
+ return current;
19
+ })();
20
+
21
+ export const configPath = path.join(home, "config.json");
22
+ export const dataDir = path.join(home, "data");
23
+ export const logsDir = path.join(home, "logs");
24
+
25
+ export function ensureDirs(): void {
26
+ for (const dir of [home, dataDir, logsDir]) fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
27
+ }
28
+
29
+ /** 原子写:先写临时文件再 rename,进程中途被杀也不会留下半截 JSON。 */
30
+ export function writeJsonAtomic(file: string, value: unknown, mode = 0o600): void {
31
+ fs.mkdirSync(path.dirname(file), { recursive: true });
32
+ const tmp = `${file}.${process.pid}.tmp`;
33
+ fs.writeFileSync(tmp, JSON.stringify(value, null, 2), { mode });
34
+ fs.renameSync(tmp, file);
35
+ }
36
+
37
+ export function readJson<T>(file: string, fallback: T): T {
38
+ try {
39
+ return JSON.parse(fs.readFileSync(file, "utf8")) as T;
40
+ } catch {
41
+ return fallback;
42
+ }
43
+ }
package/src/main.ts ADDED
@@ -0,0 +1,68 @@
1
+ // 入口:拉起 agent 驱动、飞书机器人和 Web 服务。
2
+ import { config, loadConfig, onConfigChange } from "./core/config.js";
3
+ import { hub } from "./core/hub.js";
4
+ import { flushStore, getConversation, isExpired } from "./core/store.js";
5
+ import { sweepUploads } from "./core/uploads.js";
6
+ import { ClaudeDriver } from "./agents/claude/driver.js";
7
+ import { CodexDriver } from "./agents/codex/driver.js";
8
+ import { startFeishu, stopFeishu } from "./channels/feishu/index.js";
9
+ import { startWeb, stopWeb } from "./channels/web/server.js";
10
+ import { home } from "./home.js";
11
+
12
+ async function startDriver(id: "claude" | "codex"): Promise<void> {
13
+ const driver = hub.driver(id);
14
+ if (!driver) return;
15
+ try {
16
+ await driver.start();
17
+ } catch (err) {
18
+ const diagnosis = driver.diagnose(err);
19
+ console.error(`[${id}] 启动失败:${diagnosis.consoleHint}`);
20
+ }
21
+ }
22
+
23
+ async function main(): Promise<void> {
24
+ loadConfig();
25
+ console.log(`[启动] agent-loft,配置目录 ${home}`);
26
+
27
+ hub.registerDriver(new ClaudeDriver());
28
+ hub.registerDriver(new CodexDriver());
29
+ if (config().agents.claude.enabled) await startDriver("claude");
30
+ if (config().agents.codex.enabled) await startDriver("codex");
31
+ // Web 上新启用了 Codex:现拉 app-server
32
+ onConfigChange((next, prev) => {
33
+ if (next.agents.codex.enabled && !prev.agents.codex.enabled) void startDriver("codex");
34
+ });
35
+
36
+ sweepUploads((key) => {
37
+ const conv = getConversation(key);
38
+ return !!conv && !isExpired(conv);
39
+ });
40
+
41
+ startFeishu();
42
+ if (config().web.enabled) await startWeb();
43
+
44
+ console.log(`[启动] workspace=${config().workspaceRoot}`);
45
+ if (config().workspaceRoot === process.cwd()) {
46
+ console.warn("[启动] ⚠️ 工作目录就是 agent-loft 自身目录,agent 会在它的代码里干活。到设置里改 workspaceRoot。");
47
+ }
48
+
49
+ let stopping = false;
50
+ const shutdown = async (signal: string) => {
51
+ if (stopping) return;
52
+ stopping = true;
53
+ console.log(`[退出] 收到 ${signal},正在收尾…`);
54
+ const force = setTimeout(() => process.exit(0), 8000);
55
+ force.unref();
56
+ stopFeishu();
57
+ await stopWeb();
58
+ await hub.shutdown().catch(() => {});
59
+ flushStore();
60
+ process.exit(0);
61
+ };
62
+ process.on("SIGTERM", () => void shutdown("SIGTERM"));
63
+ process.on("SIGINT", () => void shutdown("SIGINT"));
64
+ process.on("exit", (code) => console.log(`[退出] agent-loft 结束,退出码 ${code}`));
65
+ process.on("unhandledRejection", (err) => console.error("[未处理的异常]", err));
66
+ }
67
+
68
+ void main();