agent-loft 0.2.29 → 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.
Files changed (69) hide show
  1. package/README.md +5 -0
  2. package/package.json +1 -1
  3. package/src/agents/claude/driver.ts +3 -2
  4. package/src/agents/claude/session.ts +11 -4
  5. package/src/agents/codex/session.ts +9 -3
  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 +4 -0
  9. package/src/channels/web/server.ts +14 -2
  10. package/src/channels/web/tasks-api.ts +94 -0
  11. package/src/core/chat-mode.ts +14 -14
  12. package/src/core/hub.ts +95 -16
  13. package/src/core/memory-mcp.ts +1 -1
  14. package/src/core/personal.ts +3 -1
  15. package/src/core/store.ts +2 -0
  16. package/src/core/task-prompt.ts +38 -0
  17. package/src/core/task-runner.ts +232 -0
  18. package/src/core/task-schedule.ts +250 -0
  19. package/src/core/tasks-bridge.ts +252 -0
  20. package/src/core/tasks-mcp.ts +55 -0
  21. package/src/core/tasks.ts +281 -0
  22. package/src/core/types.ts +2 -0
  23. package/src/main.ts +8 -0
  24. package/src/mcp/tasks-server.ts +206 -0
  25. package/web/app.js +8 -5560
  26. package/web/css/base.css +291 -0
  27. package/web/css/composer.css +506 -0
  28. package/web/css/loader.css +196 -0
  29. package/web/css/messages.css +734 -0
  30. package/web/css/mobile.css +100 -0
  31. package/web/css/overlay.css +437 -0
  32. package/web/css/schedule.css +93 -0
  33. package/web/css/settings.css +335 -0
  34. package/web/css/sidebar.css +593 -0
  35. package/web/css/tasks.css +455 -0
  36. package/web/css/topbar.css +205 -0
  37. package/web/index.html +12 -1
  38. package/web/lib/api.js +26 -0
  39. package/web/lib/dom.js +101 -0
  40. package/web/lib/markdown.js +52 -0
  41. package/web/lib/overlay.js +144 -0
  42. package/web/lib/push.js +70 -0
  43. package/web/lib/time.js +60 -0
  44. package/web/lib/ws.js +236 -0
  45. package/web/settings/agents.js +210 -0
  46. package/web/settings/commands.js +411 -0
  47. package/web/settings/common.js +42 -0
  48. package/web/settings/feishu.js +181 -0
  49. package/web/settings/general.js +184 -0
  50. package/web/settings/index.js +63 -0
  51. package/web/settings/memory.js +366 -0
  52. package/web/settings/notify.js +80 -0
  53. package/web/settings/security.js +188 -0
  54. package/web/settings/users.js +189 -0
  55. package/web/state.js +46 -0
  56. package/web/views/auth.js +61 -0
  57. package/web/views/composer.js +324 -0
  58. package/web/views/conv-actions.js +412 -0
  59. package/web/views/interaction.js +140 -0
  60. package/web/views/layout.js +197 -0
  61. package/web/views/messages.js +531 -0
  62. package/web/views/quota.js +145 -0
  63. package/web/views/schedule.js +120 -0
  64. package/web/views/sidebar.js +458 -0
  65. package/web/views/slash.js +185 -0
  66. package/web/views/tasks.js +460 -0
  67. package/web/views/topbar.js +400 -0
  68. package/web/views/voice.js +189 -0
  69. package/web/style.css +0 -3500
package/src/core/hub.ts CHANGED
@@ -46,6 +46,8 @@ import type {
46
46
  import { buildCatalog, expandCustomCommand, type AgentCommand, type CommandEntry, type CommandPrefs } from "./command-catalog.js";
47
47
  import { helpText, type ParsedCommand } from "./commands.js";
48
48
  import { listScheduled } from "./scheduled.js";
49
+ import { stripContext } from "./chat-mode.js";
50
+ import { isSilentReply } from "./task-prompt.js";
49
51
 
50
52
  // ---------- 事件 ----------
51
53
 
@@ -66,12 +68,22 @@ export type ConvEvent =
66
68
  | { type: "step"; kind: StepKind; text: string }
67
69
  | { type: "delta"; text: string }
68
70
  | { type: "text"; text: string }
69
- | { type: "result"; text: string; footer: string; at: number; anchor?: string }
71
+ /**
72
+ * task:这一轮是定时任务发起的;silent:任务让 agent 自己判断要不要提醒,它判断这次不用 ——
73
+ * 推送、飞书都不发,网页上折叠显示
74
+ */
75
+ | { type: "result"; text: string; footer: string; at: number; anchor?: string; task?: { id: string; title: string }; silent?: true }
70
76
  | { type: "error"; message: string }
71
77
  | { type: "notice"; text: string }
72
78
  | { type: "state"; busy: boolean }
73
79
  | { type: "meta" };
74
80
 
81
+ /** 定时任务发起的一轮(见 core/task-runner.ts) */
82
+ export type TaskTurn = { taskId: string; runId: string; title: string; smart: boolean };
83
+
84
+ /** 定时任务的一轮结束了 */
85
+ export type TaskTurnEnd = TaskTurn & { key: string; status: "notified" | "silent" | "error" | "interrupted"; text: string };
86
+
75
87
  /** 一条输入是在哪个时刻收到的:停止代次 + 生命周期代次 */
76
88
  /**
77
89
  * 一条输入收到时的状态快照。
@@ -141,6 +153,8 @@ export type ConversationInfo = {
141
153
  personal?: boolean;
142
154
  /** 排着的定时消息(见 core/scheduled.ts),按发送时间先后 */
143
155
  scheduled?: { id: string; text: string; at: number }[];
156
+ /** 定时任务的专用对话 */
157
+ taskId?: string;
144
158
  };
145
159
 
146
160
  /** 普通成员点了会落盘的「总是允许」 */
@@ -162,9 +176,14 @@ function withTimeout<T>(p: Promise<T>, what: string, ms = QUERY_TIMEOUT_MS): Pro
162
176
 
163
177
  type Live = { id: number; agent: string; cwd: string; session: AgentSession };
164
178
 
165
- class Hub extends EventEmitter<{ event: [EventEnvelope] }> {
179
+ class Hub extends EventEmitter<{ event: [EventEnvelope]; task: [TaskTurnEnd] }> {
166
180
  private drivers = new Map<string, AgentDriver>();
167
181
  private live = new Map<string, Live>();
182
+ /**
183
+ * 每条会话已经送进 agent、还没出结果的轮次,按先后排:定时任务发起的带着 TaskTurn,普通消息是 null。
184
+ * 和 inflight 一样一轮一项,出结果 / 出错 / 被丢弃时从头上取走。
185
+ */
186
+ private turnTags = new Map<string, (TaskTurn | null)[]>();
168
187
  private buffers = new Map<string, EventEnvelope[]>();
169
188
  private seq = 0;
170
189
  /** 每条会话还没收到结果的轮数,> 0 即忙 */
@@ -446,6 +465,7 @@ class Hub extends EventEmitter<{ event: [EventEnvelope] }> {
446
465
  ...(this.drivers.get(conv.agent)?.forkAt ? { canFork: true } : {}),
447
466
  mode: conv.mode ?? "work",
448
467
  ...(memoryOwnerOf(conv) ? { personal: true } : {}),
468
+ ...(conv.taskId ? { taskId: conv.taskId } : {}),
449
469
  ...(() => {
450
470
  const list = listScheduled(key);
451
471
  return list.length ? { scheduled: list.map(({ id, text, at }) => ({ id, text, at })) } : {};
@@ -498,6 +518,8 @@ class Hub extends EventEmitter<{ event: [EventEnvelope] }> {
498
518
  // 记忆在拉起会话这一刻读一次:会话里 agent 自己记下的,上下文里本来就有;
499
519
  // 用户在设置里改了,memoryChanged() 会让空闲的会话下次说话时带着新的重新拉起
500
520
  const memory = sessionMemory(conv);
521
+ // 定时任务工具:和记忆一样只给有主人的对话(飞书群聊里谁都能让机器人建任务,算不清是谁的)
522
+ const taskOwner = memoryOwnerOf(conv);
501
523
  const mode = conv.mode ?? "work";
502
524
  // 续着的会话上次被告知的模式和现在不一样(新会话没有历史,算原装的工作模式)
503
525
  const announceMode = (resumeId ? (binding.announcedMode ?? "work") : "work") !== mode;
@@ -520,6 +542,7 @@ class Hub extends EventEmitter<{ event: [EventEnvelope] }> {
520
542
  ...(conv.mode === "chat" ? { mode: "chat" as const } : {}),
521
543
  ...(memory ? { memory } : {}),
522
544
  ...(announceMode ? { announceMode: true } : {}),
545
+ ...(taskOwner ? { tasks: { userId: taskOwner, convKey: key } } : {}),
523
546
  },
524
547
  {
525
548
  onTurnStart: () => {
@@ -536,13 +559,18 @@ class Hub extends EventEmitter<{ event: [EventEnvelope] }> {
536
559
  if (result.costUsd !== undefined) parts.push(`约 $${result.costUsd.toFixed(4)}`);
537
560
  if (result.tokens !== undefined) parts.push(`${result.tokens} tokens`);
538
561
  const footer = parts.join(" ");
562
+ const tag = this.takeTurnTag(key);
563
+ const silent = !!tag?.smart && isSilentReply(result.text);
539
564
  this.emitEvent(key, {
540
565
  type: "result",
541
566
  text: result.text,
542
567
  footer,
543
568
  at: Date.now(),
544
569
  ...(result.anchor ? { anchor: result.anchor } : {}),
570
+ ...(tag ? { task: { id: tag.taskId, title: tag.title } } : {}),
571
+ ...(silent ? { silent: true as const } : {}),
545
572
  });
573
+ if (tag) this.emit("task", { ...tag, key, status: silent ? "silent" : "notified", text: result.text });
546
574
  const conv = getConversation(key);
547
575
  recordFooter(holder.session?.sessionId ?? (conv ? agentBinding(conv, agent).sessionId : undefined), result.text, footer);
548
576
  this.setInflight(key, (this.inflight.get(key) ?? 1) - 1);
@@ -560,7 +588,12 @@ class Hub extends EventEmitter<{ event: [EventEnvelope] }> {
560
588
  console.error(`[${agent}] ${key} 出错:`, err instanceof Error ? err.message : err);
561
589
  // 这一轮已经结束了,挂着的审批没人会再等它
562
590
  interactions.cancelAll(key, "本轮出错结束");
563
- if (Date.now() - (this.stoppedAt.get(key) ?? 0) < 15_000) {
591
+ const stopped = Date.now() - (this.stoppedAt.get(key) ?? 0) < 15_000;
592
+ const tag = this.takeTurnTag(key);
593
+ if (tag) {
594
+ this.emit("task", { ...tag, key, status: stopped ? "interrupted" : "error", text: stopped ? "被停止了" : diagnosis.chatMessage });
595
+ }
596
+ if (stopped) {
564
597
  // 用户自己点的停止:中断导致的「没能正常收尾」不是故障
565
598
  this.emitEvent(key, { type: "notice", text: "已中断。" });
566
599
  } else {
@@ -584,6 +617,11 @@ class Hub extends EventEmitter<{ event: [EventEnvelope] }> {
584
617
  },
585
618
  onDropped: (n) => {
586
619
  if (!alive() || n <= 0) return;
620
+ // 丢的是排在最后、还没开始的那 n 条(Codex 先报正在跑的那轮出错、再报丢弃,那时队头已经取走了)
621
+ const tags = this.turnTags.get(key) ?? [];
622
+ for (const tag of tags.splice(Math.max(0, tags.length - n), n)) {
623
+ if (tag) this.emit("task", { ...tag, key, status: "interrupted", text: "排队时被停止了" });
624
+ }
587
625
  this.setInflight(key, (this.inflight.get(key) ?? n) - n);
588
626
  },
589
627
  onSessionId: (sessionId) => {
@@ -656,6 +694,8 @@ class Hub extends EventEmitter<{ event: [EventEnvelope] }> {
656
694
  const existing = this.live.get(key);
657
695
  if (existing) this.rememberRuntime(key, existing);
658
696
  this.live.delete(key);
697
+ for (const tag of this.turnTags.get(key) ?? []) if (tag) this.emit("task", { ...tag, key, status: "interrupted", text: note });
698
+ this.turnTags.delete(key);
659
699
  this.setInflight(key, 0);
660
700
  if (!existing) return;
661
701
  // dispose 卡住(CLI 不响应)不能把这条会话的操作队列永远堵死
@@ -747,9 +787,10 @@ class Hub extends EventEmitter<{ event: [EventEnvelope] }> {
747
787
  * gen:这条消息是在哪个停止代次下收到的。渠道自己还要先做点异步的事(飞书下载附件)
748
788
  * 才调到这里时必须传:下载期间点了停止,这条消息就不该再发出去。
749
789
  */
750
- send(key: string, input: UserInput, origin: Origin, gen?: InputGen): void {
790
+ send(key: string, input: UserInput, origin: Origin, gen?: InputGen, opts: { task?: TaskTurn } = {}): void {
751
791
  const conv = getConversation(key);
752
792
  if (!conv) throw new Error(`会话不存在:${key}`);
793
+ const task = opts.task;
753
794
  // 用户自定义命令(设置 → 命令):展开成预设的提示词再发,界面上和 agent 收到的都是展开后的内容
754
795
  const custom = (config().commands as Record<string, CommandPrefs | undefined>)[conv.agent]?.custom;
755
796
  const expanded = expandCustomCommand(input.text, custom);
@@ -772,10 +813,14 @@ class Hub extends EventEmitter<{ event: [EventEnvelope] }> {
772
813
  return false;
773
814
  };
774
815
  // 最先检查:停止之前收到的旧消息,不能去回答停止之后新冒出来的提问
775
- if (cancelled()) return;
816
+ if (cancelled()) {
817
+ if (task) this.emit("task", { ...task, key, status: "interrupted", text: "没有发出(停止了或者会话被切换了)" });
818
+ return;
819
+ }
776
820
  const shown = {
777
821
  type: "user" as const,
778
- text: input.text,
822
+ // 附给 agent 的背景(定时任务的说明)不显示
823
+ text: stripContext(input.text),
779
824
  attachments: input.attachments.map((a) => `${KIND_LABEL[a.kind]} ${a.name}`),
780
825
  files: input.attachments.map(attachmentInfo),
781
826
  origin,
@@ -787,7 +832,8 @@ class Hub extends EventEmitter<{ event: [EventEnvelope] }> {
787
832
  * 没有切换生效过;expectId 给了的话,还必须正是收到时挂着的那一个(不能去回答后来才冒出的新提问)。
788
833
  */
789
834
  const tryAnswer = (expectId?: string): boolean => {
790
- if (input.attachments.length > 0) return false;
835
+ // 定时任务的提示词不是给挂着的提问的回答
836
+ if (task || input.attachments.length > 0) return false;
791
837
  const asking = interactions.findQuestion(key);
792
838
  if (!asking || asking.owner === undefined || asking.owner !== this.liveSessionId(key)) return false;
793
839
  if (expectId !== undefined && asking.id !== expectId) return false;
@@ -807,28 +853,49 @@ class Hub extends EventEmitter<{ event: [EventEnvelope] }> {
807
853
  // 闲置过期要按这次说话之前的活跃时间判断,所以先记下、后 touch
808
854
  const lastActive = conv.activeAt;
809
855
  touchConversation(key);
810
- if (!conv.title && conv.channel === "web" && input.text.trim()) {
856
+ if (!conv.title && conv.channel === "web" && shown.text.trim()) {
811
857
  // 先用首条消息占位,第一轮结束后换成 agent 自己生成的短标题(syncTitle)
812
- updateConversation(key, { title: input.text.trim().replace(/\s+/g, " ").slice(0, 40), titleAuto: true });
858
+ updateConversation(key, { title: shown.text.trim().replace(/\s+/g, " ").slice(0, 40), titleAuto: true });
813
859
  this.emitEvent(key, { type: "meta" });
814
860
  }
815
861
  this.emitEvent(key, shown);
862
+ const gone = (text: string) => {
863
+ if (task) this.emit("task", { ...task, key, status: "interrupted", text });
864
+ };
816
865
  void this.serial(key, async () => {
817
- if (cancelled()) return;
866
+ if (cancelled()) return gone("没有发出(停止了或者会话被切换了)");
818
867
  if (questionAtEntry && tryAnswer(questionAtEntry)) return;
819
- // 收到之后发生过显式切换(恢复 / 分叉 / 新会话…):用户已经明确指定了会话,不再按旧的闲置时间作废它
868
+ // 收到之后发生过显式切换(恢复 / 分叉 / 新会话…):用户已经明确指定了会话,不再按旧的闲置时间作废它。
869
+ // 定时任务也不按闲置过期:每天跑一次的任务要接着上次的上下文
820
870
  const ttl = config().sessionTtlMs;
821
871
  const switchedAfterEntry = (this.lastSwitch.get(key)?.at ?? 0) >= g.at;
822
- const expired = !switchedAfterEntry && ttl > 0 && Date.now() - lastActive > ttl;
872
+ const expired = !task && !switchedAfterEntry && ttl > 0 && Date.now() - lastActive > ttl;
823
873
  if (expired) {
824
874
  await this.expireNow(key);
825
875
  // 清理要等旧会话 dispose,期间可能又点了停止
826
876
  if (cancelled()) return;
827
877
  }
828
- this.sendNow(key, input);
878
+ this.sendNow(key, input, task ?? null);
829
879
  });
830
880
  }
831
881
 
882
+ /** 刚点过停止(被打断的那一轮还可能晚到一个出错,见 onError 里的 stopped) */
883
+ recentlyStopped(key: string): boolean {
884
+ return Date.now() - (this.stoppedAt.get(key) ?? 0) < 15_000;
885
+ }
886
+
887
+ /** 这条会话里正在跑的那一轮是不是定时任务发起的 */
888
+ currentTaskTurn(key: string): TaskTurn | undefined {
889
+ return this.turnTags.get(key)?.[0] ?? undefined;
890
+ }
891
+
892
+ private takeTurnTag(key: string): TaskTurn | null {
893
+ const tags = this.turnTags.get(key);
894
+ const tag = tags?.shift() ?? null;
895
+ if (tags && tags.length === 0) this.turnTags.delete(key);
896
+ return tag;
897
+ }
898
+
832
899
  /** 闲置太久:不再续用上次的会话(活着的也丢掉),开新的 */
833
900
  private async expireNow(key: string): Promise<void> {
834
901
  const conv = getConversation(key);
@@ -842,20 +909,29 @@ class Hub extends EventEmitter<{ event: [EventEnvelope] }> {
842
909
  this.emitEvent(key, { type: "meta" });
843
910
  }
844
911
 
845
- private sendNow(key: string, input: UserInput): void {
912
+ private sendNow(key: string, input: UserInput, tag: TaskTurn | null): void {
913
+ const fail = (err: unknown) => {
914
+ const message = err instanceof Error ? err.message : String(err);
915
+ this.emitEvent(key, { type: "error", message });
916
+ if (tag) this.emit("task", { ...tag, key, status: "error", text: message });
917
+ };
846
918
  let session: AgentSession;
847
919
  try {
848
920
  session = this.session(key);
849
921
  } catch (err) {
850
- this.emitEvent(key, { type: "error", message: err instanceof Error ? err.message : String(err) });
922
+ fail(err);
851
923
  return;
852
924
  }
853
925
  this.setInflight(key, (this.inflight.get(key) ?? 0) + 1);
926
+ const tags = this.turnTags.get(key) ?? [];
927
+ tags.push(tag);
928
+ this.turnTags.set(key, tags);
854
929
  try {
855
930
  session.send(input);
856
931
  } catch (err) {
932
+ tags.pop();
857
933
  this.setInflight(key, (this.inflight.get(key) ?? 1) - 1);
858
- this.emitEvent(key, { type: "error", message: err instanceof Error ? err.message : String(err) });
934
+ fail(err);
859
935
  }
860
936
  }
861
937
 
@@ -1125,6 +1201,9 @@ class Hub extends EventEmitter<{ event: [EventEnvelope] }> {
1125
1201
  this.stoppedAt.set(key, Date.now());
1126
1202
  interactions.cancelAll(key, "本轮已中断");
1127
1203
  await this.live.get(key)?.session.interrupt();
1204
+ // 和 inflight 一起清零:被打断的那一轮不一定还会报结果,留着的话下一轮的结果会被当成定时任务的
1205
+ for (const tag of this.turnTags.get(key) ?? []) if (tag) this.emit("task", { ...tag, key, status: "interrupted", text: "被停止了" });
1206
+ this.turnTags.delete(key);
1128
1207
  this.setInflight(key, 0);
1129
1208
  return "已请求中断。";
1130
1209
  }
@@ -9,7 +9,7 @@ export const MEMORY_MCP_NAME = "agent_loft_memory";
9
9
  const serverPath = fileURLToPath(new URL("../mcp/memory-server.ts", import.meta.url));
10
10
 
11
11
  /** tsx 的 ESM loader:源码是 TypeScript,子进程和主进程一样靠它直接跑 */
12
- const tsxLoader = (() => {
12
+ export const tsxLoader = (() => {
13
13
  const require = createRequire(import.meta.url);
14
14
  // tsx/esm 的导出只有 import 条件,require.resolve 解不到;从包目录拼出来
15
15
  const pkg = require.resolve("tsx/package.json");
@@ -2,6 +2,7 @@
2
2
  import { deleteMemory, memoryPrompt, readMemory } from "./memory.js";
3
3
  import { purgeHistory } from "./chat-history.js";
4
4
  import { unlinkUser } from "./feishu-links.js";
5
+ import { deleteUserTasks } from "./tasks.js";
5
6
  import type { Conversation } from "./store.js";
6
7
  import { getUser, legacyOwnerId } from "./users.js";
7
8
 
@@ -36,9 +37,10 @@ export function sessionMemory(
36
37
  };
37
38
  }
38
39
 
39
- /** 删用户时一起删掉的个人数据:记忆、聊天记录索引和飞书绑定 */
40
+ /** 删用户时一起删掉的个人数据:记忆、聊天记录索引、飞书绑定和定时任务 */
40
41
  export function purgeUserData(userId: string): void {
41
42
  deleteMemory(userId);
42
43
  purgeHistory(userId);
43
44
  unlinkUser(userId);
45
+ deleteUserTasks(userId);
44
46
  }
package/src/core/store.ts CHANGED
@@ -59,6 +59,8 @@ export type Conversation = {
59
59
  owner?: string;
60
60
  /** chat:聊天模式(/chat)。没有就是工作模式。属于对话,跨 /new 和切 agent 保留 */
61
61
  mode?: "chat";
62
+ /** 定时任务的专用对话:是哪个任务的(见 core/tasks.ts)。任务删了就清掉 */
63
+ taskId?: string;
62
64
  };
63
65
 
64
66
  type StoreShape = Record<string, Conversation>;
@@ -0,0 +1,38 @@
1
+ // 定时任务每次运行时发给 agent 的话:用户写的提示词 + 一段 agent-loft 附上的说明(用户看不到)。
2
+ //
3
+ // 「只在需要时提醒」靠一个暗号:agent 判断这次没什么要说的,就在回复第一行只写 SILENT_MARK,
4
+ // hub 看到它就把这一轮标成 silent,推送和飞书都不发。没写暗号一律当成要提醒 —— 宁可多提醒一次,
5
+ // 也不能漏掉。
6
+ import { wrapContext } from "./chat-mode.js";
7
+ import { formatDateTime, serverTimeZone } from "./task-schedule.js";
8
+
9
+ export const SILENT_MARK = "[[静默]]";
10
+
11
+ export function isSilentReply(text: string): boolean {
12
+ return text.trimStart().startsWith(SILENT_MARK);
13
+ }
14
+
15
+ /** 去掉暗号,剩下的是 agent 顺手记的检查结果 */
16
+ export function stripSilentMark(text: string): string {
17
+ return isSilentReply(text) ? text.trimStart().slice(SILENT_MARK.length).trim() : text;
18
+ }
19
+
20
+ export function taskMessage(
21
+ task: { title: string; prompt: string; notify: "smart" | "always" },
22
+ opts: { now?: number; manual?: boolean; version?: string } = {},
23
+ ): string {
24
+ const now = opts.now ?? Date.now();
25
+ const lines = [
26
+ `这条消息是定时任务「${task.title}」${opts.manual ? "(用户刚才手动点了「立即运行」)" : "到点自动发来的"},现在是 ${formatDateTime(now)}(${serverTimeZone()})。${opts.manual ? "" : "用户此刻多半不在屏幕前,不要向他提问、等他回答。"}`,
27
+ ...(opts.version ? [`agent-loft 服务端当前版本:${opts.version}。`] : []),
28
+ "按上面的要求做;这条对话里以前运行的结果可以拿来对比(比如「和上次比有没有变化」)。",
29
+ ];
30
+ if (task.notify === "smart") {
31
+ lines.push(
32
+ "",
33
+ `用户希望只在有需要时才被提醒:如果按上面的要求,这次没有需要告诉他的事(比如没有更新、没有变化、条件没满足),回复的第一行只写 ${SILENT_MARK},后面用一两句话记下这次检查的结果(下次可以对比),他就不会收到通知。`,
34
+ `有需要告诉他的事时正常回复,开头直接说结论,不要写 ${SILENT_MARK}。`,
35
+ );
36
+ }
37
+ return `${task.prompt}\n\n${wrapContext(lines)}`;
38
+ }
@@ -0,0 +1,232 @@
1
+ // 定时任务的调度:每 30 秒看一眼哪些到点了,核对主人和对话,把提示词送进对话;一轮结束后记下结果。
2
+ //
3
+ // 到点时的核对(任何一条不满足都不跑):
4
+ // - 主人还在、没被停用;
5
+ // - 回到的对话还在、没被删,而且还是他的(飞书私聊换绑了账号之类会让对话换主人)—— 不满足就暂停任务;
6
+ // - 对话没在忙、也没挂着授权 / 提问:忙的话等它(最多 WAIT_MAX_MS),不能插进别人正在进行的那一轮,
7
+ // 更不能被当成对挂着的提问的回答。
8
+ import crypto from "node:crypto";
9
+ import { config, resolveWorkspacePath } from "./config.js";
10
+ import { hub, type TaskTurnEnd } from "./hub.js";
11
+ import { interactions } from "./interactions.js";
12
+ import { memoryOwnerOf } from "./personal.js";
13
+ import { getConversation, updateAgentBinding, updateConversation, type Conversation } from "./store.js";
14
+ import { formatDateTime } from "./task-schedule.js";
15
+ import { stripSilentMark, taskMessage } from "./task-prompt.js";
16
+ import { advance, allTasks, finishRun, pauseTask, persistTask, pushRun, runId, type Task, type TaskRun } from "./tasks.js";
17
+ import { RUNNING_VERSION } from "./upgrade.js";
18
+ import { getUser } from "./users.js";
19
+
20
+ const TICK_MS = 30_000;
21
+ /** 服务停着的时候错过的:错过不到这么久的,启动后补跑一次;更早的记一笔「错过了」 */
22
+ const CATCHUP_MS = 2 * 3600_000;
23
+ /** 到点时对话在忙:最多等这么久,还忙就跳过这一次 */
24
+ const WAIT_MAX_MS = 30 * 60_000;
25
+ /** 送出去之后对话已经不忙了、却一直没等到结果(被别的操作吞掉了):这么久之后按中断记 */
26
+ const LOST_MS = 2 * 60_000;
27
+
28
+ /** 到点了、在等对话空下来的 */
29
+ const waiting = new Map<string, { since: number }>();
30
+ /** 正在跑的:任务 id → 这次运行 */
31
+ const running = new Map<string, { runId: string; key: string; at: number }>();
32
+ let timer: NodeJS.Timeout | null = null;
33
+
34
+ /** 这条对话现在归谁(和记忆同一个规则:飞书群聊、没绑定的私聊没有主人) */
35
+ function ownerOf(conv: Conversation): string | undefined {
36
+ return memoryOwnerOf(conv);
37
+ }
38
+
39
+ function preview(text: string, max = 120): string {
40
+ const plain = text
41
+ .replace(/```[\s\S]*?```/g, " [代码] ")
42
+ .replace(/[`*_~#>|]/g, "")
43
+ .replace(/\s+/g, " ")
44
+ .trim();
45
+ return plain.length > max ? `${plain.slice(0, max - 1)}…` : plain;
46
+ }
47
+
48
+ type Resolved = { key: string } | { pause: string } | { error: string };
49
+
50
+ /** 这次该发到哪条对话;专用对话还没有(或者被删了)就新建一条 */
51
+ function resolveConv(task: Task): Resolved {
52
+ if (task.target.kind === "conv") {
53
+ const conv = getConversation(task.target.key);
54
+ if (!conv || conv.hidden) return { pause: "回复的那条对话已经被删除,任务已暂停。可以改成「专用对话」后再启用。" };
55
+ if (ownerOf(conv) !== task.owner) return { pause: "回复的那条对话已经不属于你了,任务已暂停。" };
56
+ return { key: conv.key };
57
+ }
58
+ const existing = task.target.key ? getConversation(task.target.key) : undefined;
59
+ if (existing && !existing.hidden && existing.taskId === task.id && ownerOf(existing) === task.owner) return { key: existing.key };
60
+
61
+ const enabled = hub.enabledAgents().map((d) => d.id);
62
+ if (!enabled.includes(task.agent)) return { error: `${hub.label(task.agent)} 没有启用` };
63
+ let cwd: string | null = null;
64
+ if (task.cwd) {
65
+ cwd = resolveWorkspacePath(task.cwd);
66
+ if (!cwd) return { error: `工作目录 ${task.cwd} 不存在了(或者不在工作区里),改一下任务的目录` };
67
+ }
68
+ const key = `web:${crypto.randomBytes(6).toString("hex")}`;
69
+ // 侧栏里专用对话前面有时钟图标(taskId),标题不用再加
70
+ hub.ensure({ key, channel: "web", channelId: "web", defaultAgent: task.agent, owner: task.owner, title: task.title.slice(0, 80) });
71
+ updateConversation(key, {
72
+ titleAuto: false,
73
+ taskId: task.id,
74
+ ...(task.mode === "chat" ? { mode: "chat" as const } : {}),
75
+ ...(cwd && cwd !== config().workspaceRoot ? { cwd, cwdExplicit: true } : {}),
76
+ });
77
+ if (task.model || task.effort) {
78
+ updateAgentBinding(key, task.agent, { ...(task.model ? { model: task.model } : {}), ...(task.effort ? { effort: task.effort } : {}) });
79
+ }
80
+ task.target = { kind: "dedicated", key };
81
+ persistTask(task);
82
+ hub.emitEvent(key, { type: "meta" });
83
+ return { key };
84
+ }
85
+
86
+ function record(task: Task, status: TaskRun["status"], note: string, now = Date.now()): void {
87
+ pushRun(task, { id: runId(), at: now, endedAt: now, status, note });
88
+ }
89
+
90
+ /**
91
+ * 试着跑一次。manual:用户点了「立即运行」—— 对话忙就直接报错,不排队。
92
+ * 返回给人看的错误;undefined 表示已经送出去了(或者在排队等)。
93
+ */
94
+ function fire(task: Task, manual: boolean, now = Date.now()): string | undefined {
95
+ const user = getUser(task.owner);
96
+ if (!user || user.disabled) {
97
+ waiting.delete(task.id);
98
+ record(task, "skipped", "账号已停用", now);
99
+ return "账号已停用";
100
+ }
101
+ const where = resolveConv(task);
102
+ if ("pause" in where) {
103
+ waiting.delete(task.id);
104
+ pauseTask(task, where.pause, now);
105
+ return where.pause;
106
+ }
107
+ if ("error" in where) {
108
+ waiting.delete(task.id);
109
+ record(task, "error", where.error, now);
110
+ return where.error;
111
+ }
112
+ const key = where.key;
113
+ const conv = getConversation(key)!;
114
+ if (!hub.enabledAgents().some((d) => d.id === conv.agent)) {
115
+ waiting.delete(task.id);
116
+ record(task, "error", `${hub.label(conv.agent)} 没有启用`, now);
117
+ return `${hub.label(conv.agent)} 没有启用`;
118
+ }
119
+ // 刚点过停止的也当忙:被打断的那一轮可能还会晚到一个出错,别让它算到这次运行头上
120
+ if (hub.isBusy(key) || hub.recentlyStopped(key) || interactions.listPending(key).length > 0) {
121
+ if (manual) return "这条对话正在回复或者在等你处理授权,等它结束再试";
122
+ const since = waiting.get(task.id)?.since ?? now;
123
+ if (now - since > WAIT_MAX_MS) {
124
+ waiting.delete(task.id);
125
+ record(task, "skipped", `对话一直在忙(等了 ${WAIT_MAX_MS / 60_000} 分钟),跳过了这一次`, now);
126
+ } else {
127
+ waiting.set(task.id, { since });
128
+ }
129
+ return undefined;
130
+ }
131
+ waiting.delete(task.id);
132
+ const run = pushRun(task, { id: runId(), at: now, status: "running", convKey: key, ...(manual ? { note: "手动运行" } : {}) });
133
+ running.set(task.id, { runId: run.id, key, at: now });
134
+ console.log(`[定时任务] ${task.id}「${task.title}」${manual ? "手动运行" : "到点运行"},发到 ${key}`);
135
+ hub.send(
136
+ key,
137
+ { text: taskMessage(task, { now, manual, version: RUNNING_VERSION }), attachments: [] },
138
+ { channel: "task", channelId: task.id, name: `定时任务「${task.title}」` },
139
+ undefined,
140
+ { task: { taskId: task.id, runId: run.id, title: task.title, smart: task.notify === "smart" } },
141
+ );
142
+ return undefined;
143
+ }
144
+
145
+ function onTurnEnd(e: TaskTurnEnd): void {
146
+ const cur = running.get(e.taskId);
147
+ if (cur?.runId === e.runId) running.delete(e.taskId);
148
+ const text = e.status === "notified" || e.status === "silent" ? preview(stripSilentMark(e.text)) : "";
149
+ finishRun(e.taskId, e.runId, {
150
+ status: e.status,
151
+ endedAt: Date.now(),
152
+ ...(text ? { preview: text } : {}),
153
+ ...(e.status === "error" || e.status === "interrupted" ? { note: preview(e.text) } : {}),
154
+ });
155
+ }
156
+
157
+ function tick(): void {
158
+ const now = Date.now();
159
+ for (const [taskId, cur] of running) {
160
+ // 送出去了,对话早就不忙了,却没等到结果:被吞掉了(比如期间切了会话),别让它永远「运行中」
161
+ if (now - cur.at > LOST_MS && !hub.isBusy(cur.key) && hub.currentTaskTurn(cur.key)?.runId !== cur.runId) {
162
+ running.delete(taskId);
163
+ finishRun(taskId, cur.runId, { status: "interrupted", endedAt: now, note: "没有拿到结果" });
164
+ }
165
+ }
166
+ for (const task of allTasks()) {
167
+ if (running.has(task.id)) continue;
168
+ if (waiting.has(task.id)) {
169
+ // 等着的时候被暂停了:这次就不跑了(「一次」的任务到点时已经自动停用,照样要跑完这一次)
170
+ if (!task.enabled && task.schedule.kind !== "once") waiting.delete(task.id);
171
+ else fire(task, false, now);
172
+ continue;
173
+ }
174
+ if (!task.enabled || task.nextRunAt === undefined || task.nextRunAt > now) continue;
175
+ // 先把下一次算好再跑:跑的过程中出了什么岔子,也不会在下一个 tick 又跑一遍
176
+ advance(task, now);
177
+ fire(task, false, now);
178
+ }
179
+ }
180
+
181
+ /** 网页上点「立即运行」 */
182
+ export function runTaskNow(task: Task): string | undefined {
183
+ if (running.has(task.id)) return "这个任务正在运行";
184
+ return fire(task, true);
185
+ }
186
+
187
+ export function isTaskRunning(id: string): boolean {
188
+ return running.has(id) || waiting.has(id);
189
+ }
190
+
191
+ /** 任务删了:它的专用对话留着(里面有历史),只是不再是谁的专用对话 */
192
+ export function forgetTask(task: Task): void {
193
+ waiting.delete(task.id);
194
+ running.delete(task.id);
195
+ if (task.target.kind === "dedicated" && task.target.key) {
196
+ const conv = getConversation(task.target.key);
197
+ if (conv?.taskId === task.id) {
198
+ updateConversation(conv.key, { taskId: undefined });
199
+ hub.emitEvent(conv.key, { type: "meta" });
200
+ }
201
+ }
202
+ }
203
+
204
+ export function startTaskRunner(): void {
205
+ if (timer) return;
206
+ const now = Date.now();
207
+ for (const task of allTasks()) {
208
+ // 上次进程退出时还在跑的:结果已经拿不到了
209
+ let dirty = false;
210
+ for (const run of task.runs) {
211
+ if (run.status !== "running") continue;
212
+ Object.assign(run, { status: "interrupted", endedAt: now, note: "服务重启,这次运行被打断了" });
213
+ dirty = true;
214
+ }
215
+ if (dirty) persistTask(task);
216
+ if (task.enabled && task.nextRunAt !== undefined && task.nextRunAt < now - CATCHUP_MS) {
217
+ record(task, "skipped", `服务没在运行,错过了 ${formatDateTime(task.nextRunAt)} 这一次`, now);
218
+ advance(task, now);
219
+ }
220
+ }
221
+ hub.on("task", onTurnEnd);
222
+ timer = setInterval(tick, TICK_MS);
223
+ timer.unref();
224
+ // 等 agent 起来了再补跑错过的
225
+ setTimeout(tick, 5_000).unref();
226
+ }
227
+
228
+ export function stopTaskRunner(): void {
229
+ if (timer) clearInterval(timer);
230
+ timer = null;
231
+ hub.off("task", onTurnEnd);
232
+ }