agent-loft 0.2.30 → 0.2.32

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 (47) hide show
  1. package/README.md +11 -1
  2. package/package.json +1 -1
  3. package/src/agents/claude/driver.ts +13 -8
  4. package/src/agents/claude/session.ts +38 -6
  5. package/src/agents/codex/session.ts +32 -5
  6. package/src/channels/feishu/bot.ts +8 -4
  7. package/src/channels/feishu/turn-stream.ts +3 -2
  8. package/src/channels/web/api.ts +8 -0
  9. package/src/channels/web/http.ts +2 -2
  10. package/src/channels/web/server.ts +21 -1
  11. package/src/channels/web/skills-api.ts +225 -0
  12. package/src/channels/web/tasks-api.ts +94 -0
  13. package/src/core/chat-mode.ts +16 -16
  14. package/src/core/command-catalog.ts +8 -1
  15. package/src/core/commands.ts +3 -0
  16. package/src/core/hub.ts +147 -19
  17. package/src/core/memory-mcp.ts +1 -1
  18. package/src/core/personal.ts +5 -1
  19. package/src/core/skill-runtime.ts +359 -0
  20. package/src/core/skills-bridge.ts +288 -0
  21. package/src/core/skills.ts +675 -0
  22. package/src/core/store.ts +2 -0
  23. package/src/core/task-prompt.ts +38 -0
  24. package/src/core/task-runner.ts +232 -0
  25. package/src/core/task-schedule.ts +250 -0
  26. package/src/core/tasks-bridge.ts +256 -0
  27. package/src/core/tasks-mcp.ts +66 -0
  28. package/src/core/tasks.ts +281 -0
  29. package/src/core/types.ts +21 -0
  30. package/src/core/zip.ts +98 -0
  31. package/src/main.ts +12 -0
  32. package/src/mcp/tasks-server.ts +327 -0
  33. package/web/css/skills.css +368 -0
  34. package/web/css/tasks.css +455 -0
  35. package/web/index.html +2 -0
  36. package/web/lib/dom.js +1 -0
  37. package/web/lib/ws.js +8 -0
  38. package/web/state.js +2 -0
  39. package/web/views/composer.js +15 -3
  40. package/web/views/conv-actions.js +15 -7
  41. package/web/views/layout.js +22 -1
  42. package/web/views/mention.js +193 -0
  43. package/web/views/messages.js +23 -2
  44. package/web/views/sidebar.js +4 -0
  45. package/web/views/skills.js +703 -0
  46. package/web/views/tasks.js +469 -0
  47. package/web/views/topbar.js +9 -3
@@ -0,0 +1,256 @@
1
+ // 主进程这头的定时任务工具:mcp/tasks-server.ts 经 unix socket 把 agent 的调用转过来,这里核对身份、
2
+ // 弹授权卡、真正增删改。
3
+ //
4
+ // 几条规矩:
5
+ // - 身份只认拉起时签过名的(用户 + 对话),而且这条对话现在还得是这个人的;只碰得到他自己的任务;
6
+ // - 新建、修改、删除(暂停除外)一律弹授权卡让用户确认:定时任务等于「以后在没人看着的时候执行一段指令」,
7
+ // agent 读到的网页里要是藏着「帮我建个每小时跑的任务」,不能就这么建了;卡上没有「总是允许」;
8
+ // - 定时任务自己跑的那一轮里不能建、改、删任务,免得一个任务生出一串任务。
9
+ import fs from "node:fs";
10
+ import net from "node:net";
11
+ import path from "node:path";
12
+ import { config } from "./config.js";
13
+ import { hub } from "./hub.js";
14
+ import { interactions } from "./interactions.js";
15
+ import { memoryOwnerOf } from "./personal.js";
16
+ import { getConversation } from "./store.js";
17
+ import { forgetTask } from "./task-runner.js";
18
+ import { describeSchedule, formatDateTime, nextRun, normalizeSchedule, parseLocalDateTime, serverTimeZone, type TaskSchedule } from "./task-schedule.js";
19
+ import { TASK_TOOL_TIMEOUT_SEC, tasksSocketPath, verifyTaskCaller } from "./tasks-mcp.js";
20
+ import { createTask, deleteTask, getTask, listTasks, TASKS_PER_USER, updateTask, type Task, type TaskInput } from "./tasks.js";
21
+ import { getUser } from "./users.js";
22
+ import { SKILL_TOOL_NAMES, skillToolCall } from "./skills-bridge.js";
23
+
24
+ type Reply = { text: string; isError?: boolean };
25
+ const fail = (text: string): Reply => ({ text, isError: true });
26
+
27
+ const STATUS: Record<string, string> = {
28
+ running: "运行中",
29
+ notified: "已提醒",
30
+ silent: "无需提醒",
31
+ error: "出错",
32
+ skipped: "跳过",
33
+ interrupted: "中断",
34
+ };
35
+
36
+ /** agent 给的频率:「一次」的时间是字符串或者「多少分钟后」,换成时间戳再校验 */
37
+ function scheduleArg(raw: unknown, now: number): TaskSchedule {
38
+ const r = { ...((raw ?? {}) as Record<string, unknown>) };
39
+ if (r.kind === "once") {
40
+ r.at = r.in_minutes !== undefined ? now + Number(r.in_minutes) * 60_000 : parseLocalDateTime(String(r.at ?? ""));
41
+ }
42
+ return normalizeSchedule(r, now);
43
+ }
44
+
45
+ function whereText(task: Task, convKey: string): string {
46
+ if (task.target.kind === "dedicated") return "专用对话";
47
+ if (task.target.key === convKey) return "这条对话";
48
+ return `对话「${getConversation(task.target.key)?.title ?? "未命名"}」`;
49
+ }
50
+
51
+ function describeTask(task: Task, convKey: string): string {
52
+ const last = task.runs[0];
53
+ return [
54
+ `- [${task.id}]「${task.title}」${describeSchedule(task.schedule)}`,
55
+ task.enabled && task.nextRunAt ? `下次 ${formatDateTime(task.nextRunAt)}` : "已暂停",
56
+ task.notify === "smart" ? "有需要才提醒" : "每次都提醒",
57
+ `结果发到${whereText(task, convKey)}`,
58
+ ...(last ? [`最近一次 ${formatDateTime(last.at)} ${STATUS[last.status] ?? last.status}${last.preview ? `:${last.preview}` : last.note ? `(${last.note})` : ""}`] : []),
59
+ ].join(" · ") + `\n 提示词:${task.prompt.replace(/\s+/g, " ").slice(0, 200)}`;
60
+ }
61
+
62
+ /** 弹授权卡,等用户点。返回 true 表示同意 */
63
+ async function confirm(convKey: string, title: string, detail: string): Promise<boolean> {
64
+ const timeout = Math.min(config().approvalTimeoutMs, (TASK_TOOL_TIMEOUT_SEC - 60) * 1000);
65
+ const outcome = await interactions.create(convKey, { kind: "approval", title, detail }, timeout, hub.liveSessionId(convKey));
66
+ return outcome.settlement === "allow-once" || outcome.settlement === "allow-always" || outcome.settlement === "allow-session";
67
+ }
68
+
69
+ function card(fields: { schedule?: TaskSchedule; notify?: string; where?: string; mode?: string; prompt?: string; enabled?: boolean }, now: number): string {
70
+ const lines: string[] = [];
71
+ if (fields.schedule) {
72
+ const next = nextRun(fields.schedule, now);
73
+ lines.push(`**频率**:${describeSchedule(fields.schedule)}(${serverTimeZone()})`);
74
+ if (next) lines.push(`**下次运行**:${formatDateTime(next)}`);
75
+ }
76
+ if (fields.notify) lines.push(`**提醒**:${fields.notify === "always" ? "每次都提醒" : "有需要才提醒(由 agent 判断)"}`);
77
+ if (fields.where) lines.push(`**结果发到**:${fields.where}`);
78
+ if (fields.mode) lines.push(`**模式**:${fields.mode === "work" ? "工作模式(能执行命令、读写文件)" : "聊天模式(只能联网搜索)"}`);
79
+ if (fields.enabled !== undefined) lines.push(`**状态**:${fields.enabled ? "恢复运行" : "暂停"}`);
80
+ if (fields.prompt) lines.push("", "**到点要做的事**:", "", ...fields.prompt.split("\n").map((l) => `> ${l}`));
81
+ return lines.join("\n");
82
+ }
83
+
84
+ async function call(userId: string, convKey: string, tool: string, args: Record<string, unknown>): Promise<Reply> {
85
+ const user = getUser(userId);
86
+ if (!user || user.disabled) return fail("账号已停用。");
87
+ const conv = getConversation(convKey);
88
+ if (!conv || memoryOwnerOf(conv) !== user.id) return fail("这条对话不属于当前用户,不能管理定时任务。");
89
+ // 技能工具走的也是这条通道(见 tasks-mcp.ts 的 skillsMcpSpawn)
90
+ if (SKILL_TOOL_NAMES.includes(tool)) return skillToolCall(user.id, convKey, tool, args);
91
+ const now = Date.now();
92
+ const inTaskTurn = !!hub.currentTaskTurn(convKey);
93
+
94
+ if (tool === "list_tasks") {
95
+ const tasks = listTasks(user.id);
96
+ const head = `现在是 ${formatDateTime(now)}(${serverTimeZone()})。`;
97
+ if (tasks.length === 0) return { text: `${head}用户还没有定时任务。` };
98
+ return { text: `${head}用户有 ${tasks.length} 个定时任务(最多 ${TASKS_PER_USER} 个):\n\n${tasks.map((t) => describeTask(t, convKey)).join("\n")}` };
99
+ }
100
+
101
+ if (inTaskTurn) return fail("这一轮是定时任务自己在运行,不能在这里新建、修改或删除定时任务。需要的话告诉用户,让他来操作。");
102
+
103
+ if (tool === "create_task") {
104
+ if (listTasks(user.id).length >= TASKS_PER_USER) return fail(`用户已经有 ${TASKS_PER_USER} 个定时任务了,到上限了,请他先删掉几个。`);
105
+ let schedule: TaskSchedule;
106
+ try {
107
+ schedule = scheduleArg(args.schedule, now);
108
+ } catch (err) {
109
+ return fail(err instanceof Error ? err.message : String(err));
110
+ }
111
+ const prompt = String(args.prompt ?? "").trim();
112
+ if (!prompt) return fail("prompt 不能为空。");
113
+ const dedicated = args.reply_to === "dedicated";
114
+ const mode = dedicated ? (args.mode === "work" ? "work" : "chat") : (conv.mode ?? "work");
115
+ const input: TaskInput = {
116
+ title: args.title,
117
+ prompt,
118
+ schedule,
119
+ notify: args.notify === "always" ? "always" : "smart",
120
+ target: dedicated ? { kind: "dedicated" } : { kind: "conv", key: convKey },
121
+ agent: conv.agent,
122
+ cwd: conv.cwd,
123
+ mode,
124
+ };
125
+ const title = String(args.title ?? "").trim() || prompt.replace(/\s+/g, " ").slice(0, 60);
126
+ const ok = await confirm(
127
+ convKey,
128
+ `新建定时任务「${title}」`,
129
+ card({ schedule, notify: String(input.notify), where: dedicated ? "单独开一条对话" : "这条对话", ...(dedicated ? { mode } : {}), prompt }, now),
130
+ );
131
+ if (!ok) return fail("用户没有同意,任务没有建。");
132
+ try {
133
+ const task = createTask(user.id, input, "agent");
134
+ return {
135
+ text: `已建好 [${task.id}]「${task.title}」:${describeSchedule(task.schedule)},${task.nextRunAt ? `下次 ${formatDateTime(task.nextRunAt)}` : "没有下次"}。用户可以在网页侧栏「定时任务」里查看和修改。`,
136
+ };
137
+ } catch (err) {
138
+ return fail(err instanceof Error ? err.message : String(err));
139
+ }
140
+ }
141
+
142
+ const id = String(args.id ?? "").trim();
143
+ const task = getTask(user.id, id);
144
+ if (!task) return fail(`没有 id 为 ${id} 的定时任务。先用 list_tasks 看看。`);
145
+
146
+ if (tool === "delete_task") {
147
+ // 删了就找不回来:也要用户点一下(只是暂停的话不用问,见下面)
148
+ const ok = await confirm(
149
+ convKey,
150
+ `删除定时任务「${task.title}」`,
151
+ `${card({ schedule: task.schedule, prompt: task.prompt }, now)}\n\n删除后不会再运行,之前运行的结果还留在对话里。`,
152
+ );
153
+ if (!ok) return fail("用户没有同意,任务没有删。");
154
+ // 等用户点卡片的时候,任务可能已经在网页上删掉了
155
+ if (!deleteTask(user.id, task.id)) return fail("这个任务已经不在了。");
156
+ forgetTask(task);
157
+ return { text: `已删掉「${task.title}」。` };
158
+ }
159
+
160
+ if (tool === "update_task") {
161
+ const patch: TaskInput = {};
162
+ try {
163
+ if (args.schedule !== undefined) patch.schedule = scheduleArg(args.schedule, now);
164
+ } catch (err) {
165
+ return fail(err instanceof Error ? err.message : String(err));
166
+ }
167
+ if (args.title !== undefined) patch.title = args.title;
168
+ if (args.prompt !== undefined) patch.prompt = args.prompt;
169
+ if (args.notify !== undefined) patch.notify = args.notify;
170
+ if (args.enabled !== undefined) patch.enabled = args.enabled === true;
171
+ if (Object.keys(patch).length === 0) return fail("没有要改的字段。");
172
+ // 只是暂停:不会让它多跑什么,不用问
173
+ const pauseOnly = Object.keys(patch).length === 1 && patch.enabled === false;
174
+ if (!pauseOnly) {
175
+ const ok = await confirm(
176
+ convKey,
177
+ `修改定时任务「${task.title}」`,
178
+ card(
179
+ {
180
+ ...(patch.schedule ? { schedule: patch.schedule as TaskSchedule } : {}),
181
+ ...(patch.notify !== undefined ? { notify: String(patch.notify) } : {}),
182
+ ...(patch.enabled !== undefined ? { enabled: patch.enabled === true } : {}),
183
+ ...(patch.prompt !== undefined ? { prompt: String(patch.prompt) } : {}),
184
+ },
185
+ now,
186
+ ) + (patch.title !== undefined ? `\n\n**新名称**:${String(patch.title)}` : ""),
187
+ );
188
+ if (!ok) return fail("用户没有同意,任务没有改。");
189
+ }
190
+ try {
191
+ const updated = updateTask(user.id, task.id, patch);
192
+ return {
193
+ text: `已改好「${updated.title}」:${describeSchedule(updated.schedule)},${updated.enabled && updated.nextRunAt ? `下次 ${formatDateTime(updated.nextRunAt)}` : "已暂停"}。`,
194
+ };
195
+ } catch (err) {
196
+ return fail(err instanceof Error ? err.message : String(err));
197
+ }
198
+ }
199
+ return fail(`没有工具 ${tool}`);
200
+ }
201
+
202
+ let server: net.Server | null = null;
203
+
204
+ export function startTasksBridge(): void {
205
+ if (server) return;
206
+ fs.mkdirSync(path.dirname(tasksSocketPath), { recursive: true, mode: 0o700 });
207
+ // 上次没收拾干净留下的 socket 文件:不删的话 listen 会报 EADDRINUSE
208
+ fs.rmSync(tasksSocketPath, { force: true });
209
+ server = net.createServer((sock) => {
210
+ let buf = "";
211
+ sock.setEncoding("utf8");
212
+ sock.on("error", () => {});
213
+ sock.on("data", (chunk: string) => {
214
+ buf += chunk;
215
+ const n = buf.indexOf("\n");
216
+ if (n < 0) {
217
+ // 技能工具会带着整份 SKILL.md 和附带文件过来,放宽到 8MB
218
+ if (buf.length > 8 * 1024 * 1024) sock.destroy();
219
+ return;
220
+ }
221
+ const line = buf.slice(0, n);
222
+ buf = "";
223
+ void (async () => {
224
+ let reply: Reply;
225
+ try {
226
+ const req = JSON.parse(line) as { user?: string; conv?: string; sig?: string; tool?: string; args?: Record<string, unknown> };
227
+ const userId = String(req.user ?? "");
228
+ const convKey = String(req.conv ?? "");
229
+ if (!verifyTaskCaller(userId, convKey, String(req.sig ?? ""))) {
230
+ reply = fail("身份校验没通过(agent-loft 可能重启过),开个新会话再试。");
231
+ } else {
232
+ reply = await call(userId, convKey, String(req.tool ?? ""), req.args && typeof req.args === "object" ? req.args : {});
233
+ }
234
+ } catch (err) {
235
+ console.error("[定时任务] 处理工具调用出错:", err);
236
+ reply = fail(err instanceof Error ? err.message : String(err));
237
+ }
238
+ if (!sock.destroyed) sock.end(`${JSON.stringify(reply)}\n`);
239
+ })();
240
+ });
241
+ });
242
+ server.on("error", (err) => console.error(`[定时任务] 工具通道出错(agent 暂时用不了定时任务工具):${err.message}`));
243
+ server.listen(tasksSocketPath, () => {
244
+ try {
245
+ fs.chmodSync(tasksSocketPath, 0o600);
246
+ } catch {
247
+ // 改不了权限也不影响:调用都要带签名
248
+ }
249
+ });
250
+ }
251
+
252
+ export function stopTasksBridge(): void {
253
+ server?.close();
254
+ server = null;
255
+ fs.rmSync(tasksSocketPath, { force: true });
256
+ }
@@ -0,0 +1,66 @@
1
+ // 定时任务工具的 MCP 服务(mcp/tasks-server.ts)怎么拉起:Claude 和 Codex 的配置共用这一份。
2
+ //
3
+ // 和记忆工具不同,它自己不碰数据文件:每次工具调用都经本机的 unix socket 转给主进程(tasks-bridge.ts)——
4
+ // 调度器在主进程里(新建的任务要马上排进去),创建 / 修改前要弹授权卡,也只有主进程弹得出来。
5
+ //
6
+ // 是谁、在哪条对话里,由拉起时的环境变量定死,并带着主进程签的名:工具参数里没有用户,
7
+ // 子进程也伪造不了别人的身份(密钥只在主进程内存里,每次启动换一个)。
8
+ import crypto from "node:crypto";
9
+ import os from "node:os";
10
+ import path from "node:path";
11
+ import { fileURLToPath } from "node:url";
12
+ import { home } from "../home.js";
13
+ import { tsxLoader, type StdioSpawn } from "./memory-mcp.js";
14
+
15
+ export const TASKS_MCP_NAME = "agent_loft_tasks";
16
+
17
+ const serverPath = fileURLToPath(new URL("../mcp/tasks-server.ts", import.meta.url));
18
+
19
+ /** unix socket 路径有长度限制(macOS 104 字节):家目录太深就放到临时目录 */
20
+ export const tasksSocketPath = (() => {
21
+ const inHome = path.join(home, "run", "tasks.sock");
22
+ if (inHome.length < 100) return inHome;
23
+ const tag = crypto.createHash("sha256").update(home).digest("hex").slice(0, 12);
24
+ return path.join(os.tmpdir(), `agent-loft-${tag}.sock`);
25
+ })();
26
+
27
+ const secret = crypto.randomBytes(32);
28
+
29
+ export function signTaskCaller(userId: string, convKey: string): string {
30
+ return crypto.createHmac("sha256", secret).update(`${userId}\n${convKey}`).digest("hex");
31
+ }
32
+
33
+ export function verifyTaskCaller(userId: string, convKey: string, sig: string): boolean {
34
+ const want = Buffer.from(signTaskCaller(userId, convKey), "hex");
35
+ const got = Buffer.from(String(sig), "hex");
36
+ return got.length === want.length && crypto.timingSafeEqual(got, want);
37
+ }
38
+
39
+ /**
40
+ * 技能工具(让 agent 帮用户建、改技能)也走这条通道:同一个服务脚本,AGENT_LOFT_TOOLSET=skills 时
41
+ * 只报技能那几个工具,主进程按工具名分给 skills-bridge.ts。单独挂成一个 MCP 服务,工具名才叫得顺。
42
+ */
43
+ export const SKILLS_MCP_NAME = "agent_loft_skills";
44
+
45
+ export function skillsMcpSpawn(userId: string, convKey: string, via: string): StdioSpawn {
46
+ const base = tasksMcpSpawn(userId, convKey, via);
47
+ return { ...base, env: { ...base.env, AGENT_LOFT_TOOLSET: "skills" } };
48
+ }
49
+
50
+ export function tasksMcpSpawn(userId: string, convKey: string, via: string): StdioSpawn {
51
+ return {
52
+ command: process.execPath,
53
+ args: ["--import", tsxLoader, serverPath],
54
+ env: {
55
+ AGENT_LOFT_TASKS_SOCKET: tasksSocketPath,
56
+ AGENT_LOFT_TASKS_USER: userId,
57
+ AGENT_LOFT_TASKS_CONV: convKey,
58
+ AGENT_LOFT_TASKS_SIG: signTaskCaller(userId, convKey),
59
+ AGENT_LOFT_TASKS_VIA: via,
60
+ AGENT_LOFT_TZ: Intl.DateTimeFormat().resolvedOptions().timeZone || "",
61
+ },
62
+ };
63
+ }
64
+
65
+ /** 工具调用最多等多久(含等用户点授权卡)。Codex 默认 60 秒就超时,要显式放宽 */
66
+ export const TASK_TOOL_TIMEOUT_SEC = 20 * 60;
@@ -0,0 +1,281 @@
1
+ // 定时任务:到点让 agent 按一段提示词干活,结果回到一条对话里;agent 自己判断要不要提醒用户。
2
+ //
3
+ // 和「定时发送」(scheduled.ts)的区别:那个是替用户把一条消息晚点发出去,一次性;这个可以周期运行,
4
+ // 每次都带着「这是定时任务、没事就别打扰用户」的说明,结果按 agent 的判断决定推不推送。
5
+ //
6
+ // 按人隔离:每个任务记着主人(owner,用户 id),网页接口和 agent 的工具都只碰得到自己的。
7
+ // 到点时还要再核对一次主人和对话(见 task-runner.ts)。这里只管存取和算时间,不依赖 hub。
8
+ import crypto from "node:crypto";
9
+ import path from "node:path";
10
+ import { EventEmitter } from "node:events";
11
+ import { dataDir, readJson, writeJsonAtomic } from "../home.js";
12
+ import { describeSchedule, nextRun, normalizeSchedule, type TaskSchedule } from "./task-schedule.js";
13
+ import type { ConversationMode } from "./types.js";
14
+
15
+ /**
16
+ * 结果回到哪:
17
+ * conv:一条已有的对话(agent 在对话里帮你建的,默认回到那条对话),模式 / agent / 目录都跟着那条对话;
18
+ * dedicated:任务自己的对话,第一次运行时新建(key 记下来,之后续用),模式等按任务里的设置。
19
+ */
20
+ export type TaskTarget = { kind: "conv"; key: string } | { kind: "dedicated"; key?: string };
21
+
22
+ export type TaskRunStatus = "running" | "notified" | "silent" | "error" | "skipped" | "interrupted";
23
+
24
+ export type TaskRun = {
25
+ id: string;
26
+ at: number;
27
+ endedAt?: number;
28
+ status: TaskRunStatus;
29
+ /** 跳过 / 出错的原因,或者「手动运行」 */
30
+ note?: string;
31
+ convKey?: string;
32
+ /** 回复开头一句,列表里显示 */
33
+ preview?: string;
34
+ };
35
+
36
+ export type Task = {
37
+ id: string;
38
+ owner: string;
39
+ title: string;
40
+ prompt: string;
41
+ schedule: TaskSchedule;
42
+ /** smart:只在需要时提醒(agent 判断);always:每次都提醒 */
43
+ notify: "smart" | "always";
44
+ target: TaskTarget;
45
+ /** 专用对话的设置(target 是 conv 时不用,以那条对话为准) */
46
+ agent: string;
47
+ cwd?: string;
48
+ mode: ConversationMode;
49
+ model?: string;
50
+ effort?: string;
51
+ enabled: boolean;
52
+ createdAt: number;
53
+ updatedAt: number;
54
+ /** 谁建的:用户在页面上建的,还是 agent 在对话里建的 */
55
+ createdBy: "user" | "agent";
56
+ nextRunAt?: number;
57
+ /** 最近的在前 */
58
+ runs: TaskRun[];
59
+ };
60
+
61
+ /** 每人最多几个任务(暂停的也算) */
62
+ export const TASKS_PER_USER = 30;
63
+ const RUNS_KEPT = 20;
64
+ export const PROMPT_MAX = 4000;
65
+ export const TITLE_MAX = 60;
66
+
67
+ export const taskEvents = new EventEmitter<{ changed: [owner: string] }>();
68
+
69
+ const storePath = path.join(dataDir, "tasks.json");
70
+ let cache: Task[] | null = null;
71
+
72
+ function load(): Task[] {
73
+ cache ??= readJson<Task[]>(storePath, []).filter((t) => t && typeof t.id === "string" && typeof t.owner === "string" && t.schedule);
74
+ return cache;
75
+ }
76
+
77
+ function save(owner?: string): void {
78
+ writeJsonAtomic(storePath, load());
79
+ if (owner) taskEvents.emit("changed", owner);
80
+ }
81
+
82
+ export function resetTasksForTest(): void {
83
+ cache = null;
84
+ }
85
+
86
+ // ---------- 查询 ----------
87
+
88
+ export function allTasks(): Task[] {
89
+ return load();
90
+ }
91
+
92
+ export function listTasks(owner: string): Task[] {
93
+ return load()
94
+ .filter((t) => t.owner === owner)
95
+ .sort((a, b) => b.createdAt - a.createdAt);
96
+ }
97
+
98
+ /** 只认主人:别人的任务一律当不存在 */
99
+ export function getTask(owner: string, id: string): Task | undefined {
100
+ return load().find((t) => t.id === id && t.owner === owner);
101
+ }
102
+
103
+ export function findTask(id: string): Task | undefined {
104
+ return load().find((t) => t.id === id);
105
+ }
106
+
107
+ // ---------- 增删改 ----------
108
+
109
+ export type TaskInput = {
110
+ title?: unknown;
111
+ prompt?: unknown;
112
+ schedule?: unknown;
113
+ notify?: unknown;
114
+ target?: unknown;
115
+ agent?: unknown;
116
+ cwd?: unknown;
117
+ mode?: unknown;
118
+ model?: unknown;
119
+ effort?: unknown;
120
+ enabled?: unknown;
121
+ };
122
+
123
+ const str = (v: unknown) => (typeof v === "string" ? v.trim() : "");
124
+
125
+ function cleanTitle(v: unknown, prompt: string): string {
126
+ const t = str(v).replace(/\s+/g, " ");
127
+ return (t || prompt.replace(/\s+/g, " ")).slice(0, TITLE_MAX);
128
+ }
129
+
130
+ function cleanPrompt(v: unknown): string {
131
+ const p = str(v);
132
+ if (!p) throw new Error("要写清楚让 agent 做什么");
133
+ if (p.length > PROMPT_MAX) throw new Error(`提示词最多 ${PROMPT_MAX} 字`);
134
+ return p;
135
+ }
136
+
137
+ function cleanTarget(v: unknown, prev?: TaskTarget): TaskTarget {
138
+ const r = (v ?? {}) as Record<string, unknown>;
139
+ if (r.kind === "conv" && typeof r.key === "string" && r.key) return { kind: "conv", key: r.key };
140
+ // 改成专用对话时沿用之前那条专用对话
141
+ return prev?.kind === "dedicated" ? prev : { kind: "dedicated" };
142
+ }
143
+
144
+ export function createTask(owner: string, input: TaskInput, by: "user" | "agent", now = Date.now()): Task {
145
+ if (listTasks(owner).length >= TASKS_PER_USER) throw new Error(`每人最多 ${TASKS_PER_USER} 个定时任务,先删掉几个不用的`);
146
+ const prompt = cleanPrompt(input.prompt);
147
+ const schedule = normalizeSchedule(input.schedule, now);
148
+ const agent = str(input.agent) || "claude";
149
+ const task: Task = {
150
+ id: `t_${crypto.randomBytes(5).toString("hex")}`,
151
+ owner,
152
+ title: cleanTitle(input.title, prompt),
153
+ prompt,
154
+ schedule,
155
+ notify: input.notify === "always" ? "always" : "smart",
156
+ target: cleanTarget(input.target),
157
+ agent,
158
+ ...(str(input.cwd) ? { cwd: str(input.cwd) } : {}),
159
+ mode: input.mode === "work" ? "work" : "chat",
160
+ ...(str(input.model) ? { model: str(input.model) } : {}),
161
+ ...(str(input.effort) ? { effort: str(input.effort) } : {}),
162
+ enabled: input.enabled !== false,
163
+ createdAt: now,
164
+ updatedAt: now,
165
+ createdBy: by,
166
+ runs: [],
167
+ };
168
+ task.nextRunAt = task.enabled ? nextRun(schedule, now) : undefined;
169
+ load().push(task);
170
+ save(owner);
171
+ return task;
172
+ }
173
+
174
+ /** 只改给了的字段。改了频率或重新启用都重算下次时间 */
175
+ export function updateTask(owner: string, id: string, patch: TaskInput, now = Date.now()): Task {
176
+ const task = getTask(owner, id);
177
+ if (!task) throw new Error("这个任务不存在");
178
+ const next: Task = { ...task };
179
+ if (patch.prompt !== undefined) next.prompt = cleanPrompt(patch.prompt);
180
+ if (patch.title !== undefined) next.title = cleanTitle(patch.title, next.prompt);
181
+ if (patch.schedule !== undefined) next.schedule = normalizeSchedule(patch.schedule, now);
182
+ if (patch.notify !== undefined) next.notify = patch.notify === "always" ? "always" : "smart";
183
+ if (patch.target !== undefined) next.target = cleanTarget(patch.target, task.target);
184
+ if (patch.agent !== undefined && str(patch.agent)) next.agent = str(patch.agent);
185
+ if (patch.cwd !== undefined) {
186
+ if (str(patch.cwd)) next.cwd = str(patch.cwd);
187
+ else delete next.cwd;
188
+ }
189
+ if (patch.mode !== undefined) next.mode = patch.mode === "work" ? "work" : "chat";
190
+ for (const k of ["model", "effort"] as const) {
191
+ if (patch[k] === undefined) continue;
192
+ if (str(patch[k])) next[k] = str(patch[k]);
193
+ else delete next[k];
194
+ }
195
+ if (patch.enabled !== undefined) next.enabled = patch.enabled !== false;
196
+ // 专用对话的 agent / 目录 / 模式变了:旧的那条对话对不上了,下次新开一条
197
+ if (
198
+ next.target.kind === "dedicated" &&
199
+ next.target.key &&
200
+ (next.agent !== task.agent || next.cwd !== task.cwd || next.mode !== task.mode)
201
+ ) {
202
+ next.target = { kind: "dedicated" };
203
+ }
204
+ next.updatedAt = now;
205
+ next.nextRunAt = next.enabled ? nextRun(next.schedule, now) : undefined;
206
+ // 「一次」的时间已经过了还要启用:没有下一次
207
+ if (next.enabled && next.nextRunAt === undefined) throw new Error("这个任务已经没有下一次运行时间了,改一下时间");
208
+ // 整个换成 next:Object.assign 不会删掉 next 里去掉了的字段(清空目录 / 模型时)
209
+ for (const k of Object.keys(task) as (keyof Task)[]) if (!(k in next) || next[k] === undefined) delete task[k];
210
+ Object.assign(task, next);
211
+ save(owner);
212
+ return task;
213
+ }
214
+
215
+ export function deleteTask(owner: string, id: string): Task | undefined {
216
+ const list = load();
217
+ const i = list.findIndex((t) => t.id === id && t.owner === owner);
218
+ if (i < 0) return undefined;
219
+ const [removed] = list.splice(i, 1);
220
+ save(owner);
221
+ return removed;
222
+ }
223
+
224
+ /** 删用户时:他的任务一起删掉,不转给管理员 */
225
+ export function deleteUserTasks(owner: string): number {
226
+ const list = load();
227
+ const kept = list.filter((t) => t.owner !== owner);
228
+ const n = list.length - kept.length;
229
+ if (n === 0) return 0;
230
+ cache = kept;
231
+ save(owner);
232
+ return n;
233
+ }
234
+
235
+ // ---------- 运行记录(task-runner 用) ----------
236
+
237
+ /** 暂停并说明原因(对话被删了、换了主人……) */
238
+ export function pauseTask(task: Task, note: string, now = Date.now()): void {
239
+ task.enabled = false;
240
+ delete task.nextRunAt;
241
+ pushRun(task, { id: runId(), at: now, endedAt: now, status: "skipped", note });
242
+ }
243
+
244
+ export function runId(): string {
245
+ return `r_${crypto.randomBytes(4).toString("hex")}`;
246
+ }
247
+
248
+ /** 记一条运行(最新的在前,留最近 RUNS_KEPT 条)并落盘 */
249
+ export function pushRun(task: Task, run: TaskRun): TaskRun {
250
+ task.runs = [run, ...task.runs.filter((r) => r.id !== run.id)].slice(0, RUNS_KEPT);
251
+ save(task.owner);
252
+ return run;
253
+ }
254
+
255
+ export function finishRun(taskId: string, id: string, patch: Partial<TaskRun>): Task | undefined {
256
+ const task = findTask(taskId);
257
+ const run = task?.runs.find((r) => r.id === id);
258
+ if (!task || !run) return undefined;
259
+ Object.assign(run, patch);
260
+ save(task.owner);
261
+ return task;
262
+ }
263
+
264
+ /** 到点了:算出再下一次(「一次」跑完就停用) */
265
+ export function advance(task: Task, now = Date.now()): void {
266
+ task.nextRunAt = nextRun(task.schedule, now);
267
+ if (task.nextRunAt === undefined) task.enabled = false;
268
+ save(task.owner);
269
+ }
270
+
271
+ export function persistTask(task: Task): void {
272
+ save(task.owner);
273
+ }
274
+
275
+ // ---------- 给页面 / agent 看的 ----------
276
+
277
+ export type TaskView = Task & { summary: string };
278
+
279
+ export function viewOf(task: Task): TaskView {
280
+ return { ...task, summary: describeSchedule(task.schedule) };
281
+ }
package/src/core/types.ts CHANGED
@@ -188,6 +188,27 @@ export type SessionOptions = {
188
188
  };
189
189
  /** 模式换过、这个 agent 会话还不知道:换不了系统提示的 agent(Codex)要在第一条消息里说一声 */
190
190
  announceMode?: boolean;
191
+ /**
192
+ * 给 agent 定时任务工具(core/tasks-mcp.ts):替谁、在哪条对话里管理。只有有主人的对话才给。
193
+ * 技能工具(让 agent 帮用户建、改技能)也跟着它一起给。
194
+ */
195
+ tasks?: { userId: string; convKey: string };
196
+ /** 这条对话主人的技能(core/skill-runtime.ts)。没主人的对话不给:机器上的技能照旧,没有 agent-loft 的技能 */
197
+ skills?: SessionSkills;
198
+ };
199
+
200
+ export type SessionSkills = {
201
+ userId: string;
202
+ /** 管理员:机器上原有的技能照常能用;成员:只有 agent-loft 的技能和 agent 自带的 */
203
+ admin: boolean;
204
+ /** Claude 插件目录(里面是这个人启用的技能),一个都没有就不给 */
205
+ pluginDir?: string;
206
+ /** 启用的技能:名字、说明、SKILL.md 的路径 */
207
+ list: { name: string; description: string; path: string }[];
208
+ /** 要注入的环境变量(技能声明过、这个人填了值的) */
209
+ env: Record<string, string>;
210
+ /** 变量配没配好的说明,拼进系统提示 / 背景 */
211
+ prompt?: string;
191
212
  };
192
213
 
193
214
  export type ConversationMode = "chat" | "work";