agent-loft 0.2.30 → 0.2.31

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.
@@ -0,0 +1,250 @@
1
+ // 定时任务的「什么时候跑」:几种常用的结构化写法 + 自定义 cron,一律按服务端所在时区解释。
2
+ //
3
+ // 存结构化的而不是一律存 cron:界面上的「每天 / 工作日 / 每周…」能原样回填,描述也好写;
4
+ // 除「一次」以外都先换成 cron 再算下次时间,只有一条计算路径。
5
+
6
+ export type TaskSchedule =
7
+ | { kind: "once"; at: number }
8
+ | { kind: "daily"; time: string }
9
+ | { kind: "weekdays"; time: string }
10
+ /** days:0 = 周日 … 6 = 周六 */
11
+ | { kind: "weekly"; days: number[]; time: string }
12
+ | { kind: "monthly"; day: number; time: string }
13
+ /** 每 every 小时一次,在第 minute 分(从 0 点起算:every=6 就是 0、6、12、18 点) */
14
+ | { kind: "hourly"; every: number; minute: number }
15
+ | { kind: "cron"; expr: string };
16
+
17
+ /** 两次运行至少隔这么久:太密了会把额度跑光 */
18
+ export const MIN_GAP_MS = 15 * 60_000;
19
+ /** 「一次」最多排到多久以后 */
20
+ export const ONCE_MAX_MS = 366 * 86400_000;
21
+
22
+ export function serverTimeZone(): string {
23
+ return Intl.DateTimeFormat().resolvedOptions().timeZone || "本地时间";
24
+ }
25
+
26
+ // ---------- cron ----------
27
+
28
+ type CronSpec = { minute: Set<number>; hour: Set<number>; dom: Set<number>; month: Set<number>; dow: Set<number>; domAny: boolean; dowAny: boolean };
29
+
30
+ const RANGES: [number, number][] = [
31
+ [0, 59],
32
+ [0, 23],
33
+ [1, 31],
34
+ [1, 12],
35
+ [0, 7],
36
+ ];
37
+
38
+ function parseField(raw: string, [min, max]: [number, number], name: string): Set<number> {
39
+ const out = new Set<number>();
40
+ for (const part of raw.split(",")) {
41
+ const m = /^(\*|(\d+)(?:-(\d+))?)(?:\/(\d+))?$/.exec(part.trim());
42
+ if (!m) throw new Error(`cron 的${name}字段写得不对:${raw}`);
43
+ const step = m[4] ? Number(m[4]) : 1;
44
+ let lo = min;
45
+ let hi = max;
46
+ if (m[1] !== "*") {
47
+ lo = Number(m[2]);
48
+ // 「5/15」= 从 5 起每 15
49
+ hi = m[3] !== undefined ? Number(m[3]) : m[4] ? max : lo;
50
+ }
51
+ if (step < 1 || lo < min || hi > max || lo > hi) throw new Error(`cron 的${name}字段超出范围:${raw}`);
52
+ for (let v = lo; v <= hi; v += step) out.add(v);
53
+ }
54
+ return out;
55
+ }
56
+
57
+ export function parseCron(expr: string): CronSpec {
58
+ const fields = expr.trim().split(/\s+/);
59
+ if (fields.length !== 5) throw new Error("cron 要写 5 段:分 时 日 月 周,比如「0 2 * * *」是每天 2 点");
60
+ const names = ["分钟", "小时", "日期", "月份", "星期"];
61
+ const [minute, hour, dom, month, dowRaw] = fields.map((f, i) => parseField(f, RANGES[i]!, names[i]!));
62
+ const dow = new Set([...dowRaw!].map((d) => d % 7));
63
+ return { minute: minute!, hour: hour!, dom: dom!, month: month!, dow, domAny: fields[2] === "*", dowAny: fields[4] === "*" };
64
+ }
65
+
66
+ function dayMatches(spec: CronSpec, d: Date): boolean {
67
+ const dom = spec.dom.has(d.getDate());
68
+ const dow = spec.dow.has(d.getDay());
69
+ // 和标准 cron 一样:日期、星期都限定了的时候,满足其一就算
70
+ if (spec.domAny && spec.dowAny) return true;
71
+ if (spec.domAny) return dow;
72
+ if (spec.dowAny) return dom;
73
+ return dom || dow;
74
+ }
75
+
76
+ /** from 之后(不含)最近一次匹配的时刻;一年内都没有就是 undefined */
77
+ export function cronNext(spec: CronSpec, from: number): number | undefined {
78
+ const d = new Date(from);
79
+ d.setSeconds(0, 0);
80
+ d.setMinutes(d.getMinutes() + 1);
81
+ const limit = from + 400 * 86400_000;
82
+ while (d.getTime() <= limit) {
83
+ if (!spec.month.has(d.getMonth() + 1)) {
84
+ d.setMonth(d.getMonth() + 1, 1);
85
+ d.setHours(0, 0, 0, 0);
86
+ continue;
87
+ }
88
+ if (!dayMatches(spec, d)) {
89
+ d.setDate(d.getDate() + 1);
90
+ d.setHours(0, 0, 0, 0);
91
+ continue;
92
+ }
93
+ if (!spec.hour.has(d.getHours())) {
94
+ d.setHours(d.getHours() + 1, 0, 0, 0);
95
+ continue;
96
+ }
97
+ if (!spec.minute.has(d.getMinutes())) {
98
+ d.setMinutes(d.getMinutes() + 1, 0, 0);
99
+ continue;
100
+ }
101
+ return d.getTime();
102
+ }
103
+ return undefined;
104
+ }
105
+
106
+ // ---------- 结构化写法 ----------
107
+
108
+ function parseTime(time: string): [number, number] {
109
+ const m = /^(\d{1,2}):(\d{2})$/.exec(String(time ?? "").trim());
110
+ if (!m || Number(m[1]) > 23 || Number(m[2]) > 59) throw new Error(`时间要写成 HH:MM,比如 02:00(收到的是「${time}」)`);
111
+ return [Number(m[1]), Number(m[2])];
112
+ }
113
+
114
+ export function toCron(s: Exclude<TaskSchedule, { kind: "once" }>): string {
115
+ switch (s.kind) {
116
+ case "daily": {
117
+ const [h, m] = parseTime(s.time);
118
+ return `${m} ${h} * * *`;
119
+ }
120
+ case "weekdays": {
121
+ const [h, m] = parseTime(s.time);
122
+ return `${m} ${h} * * 1-5`;
123
+ }
124
+ case "weekly": {
125
+ const [h, m] = parseTime(s.time);
126
+ return `${m} ${h} * * ${[...new Set(s.days)].sort((a, b) => a - b).join(",")}`;
127
+ }
128
+ case "monthly": {
129
+ const [h, m] = parseTime(s.time);
130
+ return `${m} ${h} ${s.day} * *`;
131
+ }
132
+ case "hourly":
133
+ return `${s.minute} ${s.every === 1 ? "*" : `*/${s.every}`} * * *`;
134
+ case "cron":
135
+ return s.expr.trim();
136
+ }
137
+ }
138
+
139
+ const int = (v: unknown, lo: number, hi: number, what: string): number => {
140
+ const n = Number(v);
141
+ if (!Number.isInteger(n) || n < lo || n > hi) throw new Error(`${what}要是 ${lo}-${hi} 之间的整数`);
142
+ return n;
143
+ };
144
+
145
+ /** 校验并规整(来自网页表单或 agent 的工具调用,都不可信)。不合规直接抛出给人看的错误 */
146
+ export function normalizeSchedule(raw: unknown, now = Date.now()): TaskSchedule {
147
+ const r = (raw ?? {}) as Record<string, unknown>;
148
+ let s: Exclude<TaskSchedule, { kind: "once" }>;
149
+ switch (r.kind) {
150
+ case "once": {
151
+ const at = Number(r.at);
152
+ if (!Number.isFinite(at)) throw new Error("「一次」要给运行时间");
153
+ if (at < now - 60_000) throw new Error("运行时间要在现在之后");
154
+ if (at > now + ONCE_MAX_MS) throw new Error("最多只能排到一年以后");
155
+ return { kind: "once", at: Math.floor(at / 60_000) * 60_000 };
156
+ }
157
+ case "daily":
158
+ case "weekdays":
159
+ s = { kind: r.kind, time: String(r.time ?? "") };
160
+ break;
161
+ case "weekly": {
162
+ const days = Array.isArray(r.days) ? r.days.map((d) => int(d, 0, 7, "星期") % 7) : [];
163
+ if (days.length === 0) throw new Error("「每周」至少选一天");
164
+ s = { kind: "weekly", days: [...new Set(days)].sort((a, b) => a - b), time: String(r.time ?? "") };
165
+ break;
166
+ }
167
+ case "monthly":
168
+ s = { kind: "monthly", day: int(r.day, 1, 31, "日期"), time: String(r.time ?? "") };
169
+ break;
170
+ case "hourly":
171
+ s = { kind: "hourly", every: int(r.every, 1, 23, "间隔小时数"), minute: int(r.minute ?? 0, 0, 59, "分钟") };
172
+ break;
173
+ case "cron":
174
+ s = { kind: "cron", expr: String(r.expr ?? "").trim().replace(/\s+/g, " ") };
175
+ break;
176
+ default:
177
+ throw new Error("不认识的频率");
178
+ }
179
+ if ("time" in s) {
180
+ const [h, m] = parseTime(s.time);
181
+ s.time = `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
182
+ }
183
+ const spec = parseCron(toCron(s));
184
+ // 抽查接下来的几次,相邻两次不能太近
185
+ let prev = cronNext(spec, now);
186
+ if (prev === undefined) throw new Error("按这个写法一年内都不会运行");
187
+ for (let i = 0; i < 8; i++) {
188
+ const next = cronNext(spec, prev);
189
+ if (next === undefined) break;
190
+ if (next - prev < MIN_GAP_MS) throw new Error(`太频繁了:两次运行至少要隔 ${MIN_GAP_MS / 60_000} 分钟`);
191
+ prev = next;
192
+ }
193
+ return s;
194
+ }
195
+
196
+ /** from 之后下一次该跑的时刻;「一次」跑过了就没有 */
197
+ export function nextRun(s: TaskSchedule, from: number): number | undefined {
198
+ if (s.kind === "once") return s.at > from ? s.at : undefined;
199
+ try {
200
+ return cronNext(parseCron(toCron(s)), from);
201
+ } catch {
202
+ return undefined;
203
+ }
204
+ }
205
+
206
+ // ---------- 描述 ----------
207
+
208
+ const WEEK = ["日", "一", "二", "三", "四", "五", "六"];
209
+
210
+ export function formatClock(ms: number): string {
211
+ const d = new Date(ms);
212
+ return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
213
+ }
214
+
215
+ export function formatDateTime(ms: number): string {
216
+ const d = new Date(ms);
217
+ const p = (n: number) => String(n).padStart(2, "0");
218
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${formatClock(ms)}`;
219
+ }
220
+
221
+ /** 给人看的一句话:「每天 02:00」「工作日 09:30」「每周一、三 08:00」 */
222
+ export function describeSchedule(s: TaskSchedule): string {
223
+ switch (s.kind) {
224
+ case "once":
225
+ return `一次:${formatDateTime(s.at)}`;
226
+ case "daily":
227
+ return `每天 ${s.time}`;
228
+ case "weekdays":
229
+ return `工作日 ${s.time}`;
230
+ case "weekly":
231
+ return s.days.length === 7 ? `每天 ${s.time}` : `每周${s.days.map((d) => WEEK[d]).join("、")} ${s.time}`;
232
+ case "monthly":
233
+ return `每月 ${s.day} 日 ${s.time}`;
234
+ case "hourly":
235
+ return s.every === 1 ? `每小时第 ${s.minute} 分` : `每 ${s.every} 小时(第 ${s.minute} 分)`;
236
+ case "cron":
237
+ return `cron:${s.expr}`;
238
+ }
239
+ }
240
+
241
+ /**
242
+ * 「2026-10-23 15:00」这类本地时间(agent 给的),也认带时区的 ISO 写法。解析不了返回 NaN。
243
+ * 不带时区的一律按服务端时区解释 —— Date.parse 对「YYYY-MM-DDTHH:MM」本来就是按本地时间。
244
+ */
245
+ export function parseLocalDateTime(text: string): number {
246
+ const t = String(text ?? "").trim();
247
+ const m = /^(\d{4})-(\d{1,2})-(\d{1,2})[ T](\d{1,2}):(\d{2})(?::\d{2})?$/.exec(t);
248
+ if (m) return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]), Number(m[4]), Number(m[5])).getTime();
249
+ return Date.parse(t);
250
+ }
@@ -0,0 +1,252 @@
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
+
23
+ type Reply = { text: string; isError?: boolean };
24
+ const fail = (text: string): Reply => ({ text, isError: true });
25
+
26
+ const STATUS: Record<string, string> = {
27
+ running: "运行中",
28
+ notified: "已提醒",
29
+ silent: "无需提醒",
30
+ error: "出错",
31
+ skipped: "跳过",
32
+ interrupted: "中断",
33
+ };
34
+
35
+ /** agent 给的频率:「一次」的时间是字符串或者「多少分钟后」,换成时间戳再校验 */
36
+ function scheduleArg(raw: unknown, now: number): TaskSchedule {
37
+ const r = { ...((raw ?? {}) as Record<string, unknown>) };
38
+ if (r.kind === "once") {
39
+ r.at = r.in_minutes !== undefined ? now + Number(r.in_minutes) * 60_000 : parseLocalDateTime(String(r.at ?? ""));
40
+ }
41
+ return normalizeSchedule(r, now);
42
+ }
43
+
44
+ function whereText(task: Task, convKey: string): string {
45
+ if (task.target.kind === "dedicated") return "专用对话";
46
+ if (task.target.key === convKey) return "这条对话";
47
+ return `对话「${getConversation(task.target.key)?.title ?? "未命名"}」`;
48
+ }
49
+
50
+ function describeTask(task: Task, convKey: string): string {
51
+ const last = task.runs[0];
52
+ return [
53
+ `- [${task.id}]「${task.title}」${describeSchedule(task.schedule)}`,
54
+ task.enabled && task.nextRunAt ? `下次 ${formatDateTime(task.nextRunAt)}` : "已暂停",
55
+ task.notify === "smart" ? "有需要才提醒" : "每次都提醒",
56
+ `结果发到${whereText(task, convKey)}`,
57
+ ...(last ? [`最近一次 ${formatDateTime(last.at)} ${STATUS[last.status] ?? last.status}${last.preview ? `:${last.preview}` : last.note ? `(${last.note})` : ""}`] : []),
58
+ ].join(" · ") + `\n 提示词:${task.prompt.replace(/\s+/g, " ").slice(0, 200)}`;
59
+ }
60
+
61
+ /** 弹授权卡,等用户点。返回 true 表示同意 */
62
+ async function confirm(convKey: string, title: string, detail: string): Promise<boolean> {
63
+ const timeout = Math.min(config().approvalTimeoutMs, (TASK_TOOL_TIMEOUT_SEC - 60) * 1000);
64
+ const outcome = await interactions.create(convKey, { kind: "approval", title, detail }, timeout, hub.liveSessionId(convKey));
65
+ return outcome.settlement === "allow-once" || outcome.settlement === "allow-always" || outcome.settlement === "allow-session";
66
+ }
67
+
68
+ function card(fields: { schedule?: TaskSchedule; notify?: string; where?: string; mode?: string; prompt?: string; enabled?: boolean }, now: number): string {
69
+ const lines: string[] = [];
70
+ if (fields.schedule) {
71
+ const next = nextRun(fields.schedule, now);
72
+ lines.push(`**频率**:${describeSchedule(fields.schedule)}(${serverTimeZone()})`);
73
+ if (next) lines.push(`**下次运行**:${formatDateTime(next)}`);
74
+ }
75
+ if (fields.notify) lines.push(`**提醒**:${fields.notify === "always" ? "每次都提醒" : "有需要才提醒(由 agent 判断)"}`);
76
+ if (fields.where) lines.push(`**结果发到**:${fields.where}`);
77
+ if (fields.mode) lines.push(`**模式**:${fields.mode === "work" ? "工作模式(能执行命令、读写文件)" : "聊天模式(只能联网搜索)"}`);
78
+ if (fields.enabled !== undefined) lines.push(`**状态**:${fields.enabled ? "恢复运行" : "暂停"}`);
79
+ if (fields.prompt) lines.push("", "**到点要做的事**:", "", ...fields.prompt.split("\n").map((l) => `> ${l}`));
80
+ return lines.join("\n");
81
+ }
82
+
83
+ async function call(userId: string, convKey: string, tool: string, args: Record<string, unknown>): Promise<Reply> {
84
+ const user = getUser(userId);
85
+ if (!user || user.disabled) return fail("账号已停用。");
86
+ const conv = getConversation(convKey);
87
+ if (!conv || memoryOwnerOf(conv) !== user.id) return fail("这条对话不属于当前用户,不能管理定时任务。");
88
+ const now = Date.now();
89
+ const inTaskTurn = !!hub.currentTaskTurn(convKey);
90
+
91
+ if (tool === "list_tasks") {
92
+ const tasks = listTasks(user.id);
93
+ const head = `现在是 ${formatDateTime(now)}(${serverTimeZone()})。`;
94
+ if (tasks.length === 0) return { text: `${head}用户还没有定时任务。` };
95
+ return { text: `${head}用户有 ${tasks.length} 个定时任务(最多 ${TASKS_PER_USER} 个):\n\n${tasks.map((t) => describeTask(t, convKey)).join("\n")}` };
96
+ }
97
+
98
+ if (inTaskTurn) return fail("这一轮是定时任务自己在运行,不能在这里新建、修改或删除定时任务。需要的话告诉用户,让他来操作。");
99
+
100
+ if (tool === "create_task") {
101
+ if (listTasks(user.id).length >= TASKS_PER_USER) return fail(`用户已经有 ${TASKS_PER_USER} 个定时任务了,到上限了,请他先删掉几个。`);
102
+ let schedule: TaskSchedule;
103
+ try {
104
+ schedule = scheduleArg(args.schedule, now);
105
+ } catch (err) {
106
+ return fail(err instanceof Error ? err.message : String(err));
107
+ }
108
+ const prompt = String(args.prompt ?? "").trim();
109
+ if (!prompt) return fail("prompt 不能为空。");
110
+ const dedicated = args.reply_to === "dedicated";
111
+ const mode = dedicated ? (args.mode === "work" ? "work" : "chat") : (conv.mode ?? "work");
112
+ const input: TaskInput = {
113
+ title: args.title,
114
+ prompt,
115
+ schedule,
116
+ notify: args.notify === "always" ? "always" : "smart",
117
+ target: dedicated ? { kind: "dedicated" } : { kind: "conv", key: convKey },
118
+ agent: conv.agent,
119
+ cwd: conv.cwd,
120
+ mode,
121
+ };
122
+ const title = String(args.title ?? "").trim() || prompt.replace(/\s+/g, " ").slice(0, 60);
123
+ const ok = await confirm(
124
+ convKey,
125
+ `新建定时任务「${title}」`,
126
+ card({ schedule, notify: String(input.notify), where: dedicated ? "单独开一条对话" : "这条对话", ...(dedicated ? { mode } : {}), prompt }, now),
127
+ );
128
+ if (!ok) return fail("用户没有同意,任务没有建。");
129
+ try {
130
+ const task = createTask(user.id, input, "agent");
131
+ return {
132
+ text: `已建好 [${task.id}]「${task.title}」:${describeSchedule(task.schedule)},${task.nextRunAt ? `下次 ${formatDateTime(task.nextRunAt)}` : "没有下次"}。用户可以在网页侧栏「定时任务」里查看和修改。`,
133
+ };
134
+ } catch (err) {
135
+ return fail(err instanceof Error ? err.message : String(err));
136
+ }
137
+ }
138
+
139
+ const id = String(args.id ?? "").trim();
140
+ const task = getTask(user.id, id);
141
+ if (!task) return fail(`没有 id 为 ${id} 的定时任务。先用 list_tasks 看看。`);
142
+
143
+ if (tool === "delete_task") {
144
+ // 删了就找不回来:也要用户点一下(只是暂停的话不用问,见下面)
145
+ const ok = await confirm(
146
+ convKey,
147
+ `删除定时任务「${task.title}」`,
148
+ `${card({ schedule: task.schedule, prompt: task.prompt }, now)}\n\n删除后不会再运行,之前运行的结果还留在对话里。`,
149
+ );
150
+ if (!ok) return fail("用户没有同意,任务没有删。");
151
+ // 等用户点卡片的时候,任务可能已经在网页上删掉了
152
+ if (!deleteTask(user.id, task.id)) return fail("这个任务已经不在了。");
153
+ forgetTask(task);
154
+ return { text: `已删掉「${task.title}」。` };
155
+ }
156
+
157
+ if (tool === "update_task") {
158
+ const patch: TaskInput = {};
159
+ try {
160
+ if (args.schedule !== undefined) patch.schedule = scheduleArg(args.schedule, now);
161
+ } catch (err) {
162
+ return fail(err instanceof Error ? err.message : String(err));
163
+ }
164
+ if (args.title !== undefined) patch.title = args.title;
165
+ if (args.prompt !== undefined) patch.prompt = args.prompt;
166
+ if (args.notify !== undefined) patch.notify = args.notify;
167
+ if (args.enabled !== undefined) patch.enabled = args.enabled === true;
168
+ if (Object.keys(patch).length === 0) return fail("没有要改的字段。");
169
+ // 只是暂停:不会让它多跑什么,不用问
170
+ const pauseOnly = Object.keys(patch).length === 1 && patch.enabled === false;
171
+ if (!pauseOnly) {
172
+ const ok = await confirm(
173
+ convKey,
174
+ `修改定时任务「${task.title}」`,
175
+ card(
176
+ {
177
+ ...(patch.schedule ? { schedule: patch.schedule as TaskSchedule } : {}),
178
+ ...(patch.notify !== undefined ? { notify: String(patch.notify) } : {}),
179
+ ...(patch.enabled !== undefined ? { enabled: patch.enabled === true } : {}),
180
+ ...(patch.prompt !== undefined ? { prompt: String(patch.prompt) } : {}),
181
+ },
182
+ now,
183
+ ) + (patch.title !== undefined ? `\n\n**新名称**:${String(patch.title)}` : ""),
184
+ );
185
+ if (!ok) return fail("用户没有同意,任务没有改。");
186
+ }
187
+ try {
188
+ const updated = updateTask(user.id, task.id, patch);
189
+ return {
190
+ text: `已改好「${updated.title}」:${describeSchedule(updated.schedule)},${updated.enabled && updated.nextRunAt ? `下次 ${formatDateTime(updated.nextRunAt)}` : "已暂停"}。`,
191
+ };
192
+ } catch (err) {
193
+ return fail(err instanceof Error ? err.message : String(err));
194
+ }
195
+ }
196
+ return fail(`没有工具 ${tool}`);
197
+ }
198
+
199
+ let server: net.Server | null = null;
200
+
201
+ export function startTasksBridge(): void {
202
+ if (server) return;
203
+ fs.mkdirSync(path.dirname(tasksSocketPath), { recursive: true, mode: 0o700 });
204
+ // 上次没收拾干净留下的 socket 文件:不删的话 listen 会报 EADDRINUSE
205
+ fs.rmSync(tasksSocketPath, { force: true });
206
+ server = net.createServer((sock) => {
207
+ let buf = "";
208
+ sock.setEncoding("utf8");
209
+ sock.on("error", () => {});
210
+ sock.on("data", (chunk: string) => {
211
+ buf += chunk;
212
+ const n = buf.indexOf("\n");
213
+ if (n < 0) {
214
+ if (buf.length > 64 * 1024) sock.destroy();
215
+ return;
216
+ }
217
+ const line = buf.slice(0, n);
218
+ buf = "";
219
+ void (async () => {
220
+ let reply: Reply;
221
+ try {
222
+ const req = JSON.parse(line) as { user?: string; conv?: string; sig?: string; tool?: string; args?: Record<string, unknown> };
223
+ const userId = String(req.user ?? "");
224
+ const convKey = String(req.conv ?? "");
225
+ if (!verifyTaskCaller(userId, convKey, String(req.sig ?? ""))) {
226
+ reply = fail("身份校验没通过(agent-loft 可能重启过),开个新会话再试。");
227
+ } else {
228
+ reply = await call(userId, convKey, String(req.tool ?? ""), req.args && typeof req.args === "object" ? req.args : {});
229
+ }
230
+ } catch (err) {
231
+ console.error("[定时任务] 处理工具调用出错:", err);
232
+ reply = fail(err instanceof Error ? err.message : String(err));
233
+ }
234
+ if (!sock.destroyed) sock.end(`${JSON.stringify(reply)}\n`);
235
+ })();
236
+ });
237
+ });
238
+ server.on("error", (err) => console.error(`[定时任务] 工具通道出错(agent 暂时用不了定时任务工具):${err.message}`));
239
+ server.listen(tasksSocketPath, () => {
240
+ try {
241
+ fs.chmodSync(tasksSocketPath, 0o600);
242
+ } catch {
243
+ // 改不了权限也不影响:调用都要带签名
244
+ }
245
+ });
246
+ }
247
+
248
+ export function stopTasksBridge(): void {
249
+ server?.close();
250
+ server = null;
251
+ fs.rmSync(tasksSocketPath, { force: true });
252
+ }
@@ -0,0 +1,55 @@
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
+ export function tasksMcpSpawn(userId: string, convKey: string, via: string): StdioSpawn {
40
+ return {
41
+ command: process.execPath,
42
+ args: ["--import", tsxLoader, serverPath],
43
+ env: {
44
+ AGENT_LOFT_TASKS_SOCKET: tasksSocketPath,
45
+ AGENT_LOFT_TASKS_USER: userId,
46
+ AGENT_LOFT_TASKS_CONV: convKey,
47
+ AGENT_LOFT_TASKS_SIG: signTaskCaller(userId, convKey),
48
+ AGENT_LOFT_TASKS_VIA: via,
49
+ AGENT_LOFT_TZ: Intl.DateTimeFormat().resolvedOptions().timeZone || "",
50
+ },
51
+ };
52
+ }
53
+
54
+ /** 工具调用最多等多久(含等用户点授权卡)。Codex 默认 60 秒就超时,要显式放宽 */
55
+ export const TASK_TOOL_TIMEOUT_SEC = 20 * 60;