@chenglu.she/sandy 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +24 -0
- package/LICENSE +21 -0
- package/README.md +135 -0
- package/assets/sandy.png +0 -0
- package/bin/feishu-cursor-bot.js +37 -0
- package/package.json +49 -0
- package/src/ask-question.ts +228 -0
- package/src/config.ts +93 -0
- package/src/cursor-agent.ts +191 -0
- package/src/feishu-docs.ts +117 -0
- package/src/feishu-files.ts +181 -0
- package/src/feishu-markdown.ts +79 -0
- package/src/feishu-tools.ts +262 -0
- package/src/feishu.ts +434 -0
- package/src/index.ts +356 -0
- package/src/pending-store.ts +56 -0
- package/src/session-queue.ts +140 -0
- package/src/session-store.ts +54 -0
- package/src/write-hook-policy.ts +36 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import {
|
|
3
|
+
formatAnswerPrompt,
|
|
4
|
+
parseTextAnswer,
|
|
5
|
+
} from "./ask-question.js";
|
|
6
|
+
import {
|
|
7
|
+
isResetCommand,
|
|
8
|
+
resetSession,
|
|
9
|
+
runCursorAgent,
|
|
10
|
+
type AgentOutcome,
|
|
11
|
+
} from "./cursor-agent.js";
|
|
12
|
+
import { config } from "./config.js";
|
|
13
|
+
import {
|
|
14
|
+
createFeishuClients,
|
|
15
|
+
extractCardAction,
|
|
16
|
+
extractText,
|
|
17
|
+
getBotOpenId,
|
|
18
|
+
parseIncomingMessage,
|
|
19
|
+
replyAgentText,
|
|
20
|
+
replyAskQuestionCard,
|
|
21
|
+
replyText,
|
|
22
|
+
shouldHandleMessage,
|
|
23
|
+
type IncomingMessage,
|
|
24
|
+
} from "./feishu.js";
|
|
25
|
+
import {
|
|
26
|
+
downloadMessageResource,
|
|
27
|
+
parseIncomingFileContent,
|
|
28
|
+
} from "./feishu-files.js";
|
|
29
|
+
import { buildFeishuCustomTools } from "./feishu-tools.js";
|
|
30
|
+
import { PendingQuestionStore } from "./pending-store.js";
|
|
31
|
+
import { createSessionQueueManager, type QueueJob } from "./session-queue.js";
|
|
32
|
+
import { SessionStore } from "./session-store.js";
|
|
33
|
+
import { writeHookPolicy } from "./write-hook-policy.js";
|
|
34
|
+
|
|
35
|
+
const sessionStore = new SessionStore(config.sessionStorePath);
|
|
36
|
+
const pendingStore = new PendingQuestionStore(config.pendingStorePath);
|
|
37
|
+
|
|
38
|
+
let eventDispatcher: InstanceType<typeof Lark.EventDispatcher> | undefined;
|
|
39
|
+
let wsRestartTimer: ReturnType<typeof setTimeout> | undefined;
|
|
40
|
+
|
|
41
|
+
const { client, wsClient, Lark } = createFeishuClients({
|
|
42
|
+
onWsError: () => {
|
|
43
|
+
if (wsRestartTimer) return;
|
|
44
|
+
wsRestartTimer = setTimeout(() => {
|
|
45
|
+
wsRestartTimer = undefined;
|
|
46
|
+
if (!eventDispatcher) return;
|
|
47
|
+
console.warn("[ws] restarting long connection after terminal error");
|
|
48
|
+
try {
|
|
49
|
+
wsClient.start({ eventDispatcher });
|
|
50
|
+
} catch (err) {
|
|
51
|
+
console.error("[ws] restart failed:", err);
|
|
52
|
+
}
|
|
53
|
+
}, 5_000);
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
function safeFileName(name: string | undefined, fallback: string): string {
|
|
58
|
+
const base = (name || fallback).replace(/[/\\?%*:|"<>]/g, "_").trim();
|
|
59
|
+
return base.slice(0, 120) || fallback;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Download file/image/media attachments into AGENT_CWD inbox; return prompt text. */
|
|
63
|
+
async function materializeIncomingAttachment(
|
|
64
|
+
msg: IncomingMessage,
|
|
65
|
+
): Promise<string | undefined> {
|
|
66
|
+
if (!["file", "image", "media"].includes(msg.messageType)) {
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const parsed = parseIncomingFileContent(msg.content);
|
|
71
|
+
const fileKey =
|
|
72
|
+
msg.messageType === "image"
|
|
73
|
+
? parsed.imageKey || parsed.fileKey
|
|
74
|
+
: parsed.fileKey || parsed.imageKey;
|
|
75
|
+
|
|
76
|
+
if (!fileKey) {
|
|
77
|
+
throw new Error(
|
|
78
|
+
`无法解析附件 key(message_type=${msg.messageType}): ${msg.content.slice(0, 200)}`,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const resourceType =
|
|
83
|
+
msg.messageType === "image"
|
|
84
|
+
? "image"
|
|
85
|
+
: msg.messageType === "media"
|
|
86
|
+
? "media"
|
|
87
|
+
: "file";
|
|
88
|
+
|
|
89
|
+
const extGuess =
|
|
90
|
+
resourceType === "image"
|
|
91
|
+
? ".png"
|
|
92
|
+
: resourceType === "media"
|
|
93
|
+
? ".mp4"
|
|
94
|
+
: "";
|
|
95
|
+
const fileName = safeFileName(
|
|
96
|
+
parsed.fileName,
|
|
97
|
+
`${resourceType}-${Date.now()}${extGuess}`,
|
|
98
|
+
);
|
|
99
|
+
const dest = path.join(
|
|
100
|
+
config.inboxDir,
|
|
101
|
+
msg.chatId,
|
|
102
|
+
`${Date.now()}-${fileName}`,
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
await downloadMessageResource(
|
|
106
|
+
client,
|
|
107
|
+
msg.messageId,
|
|
108
|
+
fileKey,
|
|
109
|
+
resourceType,
|
|
110
|
+
dest,
|
|
111
|
+
);
|
|
112
|
+
console.log(`[file] saved ${resourceType} -> ${dest}`);
|
|
113
|
+
|
|
114
|
+
return [
|
|
115
|
+
`用户在飞书里发送了${resourceType === "image" ? "图片" : resourceType === "media" ? "媒体" : "文件"}。`,
|
|
116
|
+
`已下载到本地路径(可用 Read / 处理后再用 feishu_send_file 发回):`,
|
|
117
|
+
dest,
|
|
118
|
+
parsed.fileName ? `原始文件名:${parsed.fileName}` : "",
|
|
119
|
+
]
|
|
120
|
+
.filter(Boolean)
|
|
121
|
+
.join("\n");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function deliverOutcome(
|
|
125
|
+
sessionKey: string,
|
|
126
|
+
replyToMessageId: string,
|
|
127
|
+
chatId: string,
|
|
128
|
+
outcome: AgentOutcome,
|
|
129
|
+
): Promise<void> {
|
|
130
|
+
if (outcome.type === "needs_input") {
|
|
131
|
+
pendingStore.set(sessionKey, {
|
|
132
|
+
agentId: outcome.agentId,
|
|
133
|
+
chatId,
|
|
134
|
+
replyToMessageId,
|
|
135
|
+
title: outcome.ask.title,
|
|
136
|
+
questions: outcome.ask.questions,
|
|
137
|
+
partialText: outcome.partialText,
|
|
138
|
+
createdAt: new Date().toISOString(),
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
if (outcome.partialText) {
|
|
142
|
+
await replyAgentText(client, replyToMessageId, outcome.partialText);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
await replyAskQuestionCard(client, replyToMessageId, sessionKey, outcome.ask);
|
|
146
|
+
console.log(
|
|
147
|
+
`[ask] waiting for selection session=${sessionKey} questions=${outcome.ask.questions.length}`,
|
|
148
|
+
);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
pendingStore.delete(sessionKey);
|
|
153
|
+
await replyAgentText(client, replyToMessageId, outcome.text);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const sessionQueue = createSessionQueueManager(client, async (job: QueueJob) => {
|
|
157
|
+
const sessionKey = job.chatId;
|
|
158
|
+
try {
|
|
159
|
+
const customTools = buildFeishuCustomTools({
|
|
160
|
+
client,
|
|
161
|
+
replyToMessageId: job.messageId,
|
|
162
|
+
chatId: job.chatId,
|
|
163
|
+
});
|
|
164
|
+
const outcome = await runCursorAgent(sessionStore, sessionKey, job.prompt, {
|
|
165
|
+
customTools,
|
|
166
|
+
});
|
|
167
|
+
await deliverOutcome(sessionKey, job.messageId, job.chatId, outcome);
|
|
168
|
+
} catch (err) {
|
|
169
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
170
|
+
console.error("[handle] agent failed:", err);
|
|
171
|
+
await replyText(client, job.messageId, `处理失败:${message}`);
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
function enqueuePrompt(
|
|
176
|
+
sessionKey: string,
|
|
177
|
+
replyToMessageId: string,
|
|
178
|
+
chatId: string,
|
|
179
|
+
prompt: string,
|
|
180
|
+
): void {
|
|
181
|
+
sessionQueue.enqueue(sessionKey, {
|
|
182
|
+
messageId: replyToMessageId,
|
|
183
|
+
chatId,
|
|
184
|
+
prompt,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function continueWithAnswers(
|
|
189
|
+
sessionKey: string,
|
|
190
|
+
replyToMessageId: string,
|
|
191
|
+
answers: Array<{ questionId: string; selectedOptionIds: string[]; freeformText?: string }>,
|
|
192
|
+
): Promise<void> {
|
|
193
|
+
const pending = pendingStore.get(sessionKey);
|
|
194
|
+
if (!pending) {
|
|
195
|
+
await replyText(client, replyToMessageId, "没有待回答的选择题,直接发消息即可。");
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const prompt = formatAnswerPrompt(pending.questions, answers);
|
|
200
|
+
pendingStore.delete(sessionKey);
|
|
201
|
+
enqueuePrompt(sessionKey, replyToMessageId, pending.chatId, prompt);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function handleMessage(raw: Parameters<typeof parseIncomingMessage>[0]) {
|
|
205
|
+
const msg = parseIncomingMessage(raw);
|
|
206
|
+
|
|
207
|
+
let botOpenId: string | undefined;
|
|
208
|
+
try {
|
|
209
|
+
botOpenId = await getBotOpenId(client);
|
|
210
|
+
} catch (err) {
|
|
211
|
+
if (msg.chatType !== "p2p") {
|
|
212
|
+
console.warn("[handle] skip group message; bot open_id unavailable:", err);
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
console.warn("[handle] bot open_id unavailable; p2p continues without mention strip");
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const { text, mentionedBot } = extractText(msg, botOpenId ?? "");
|
|
219
|
+
|
|
220
|
+
if (!shouldHandleMessage(msg, mentionedBot)) {
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const sessionKey = msg.chatId;
|
|
225
|
+
|
|
226
|
+
let attachmentPrompt: string | undefined;
|
|
227
|
+
try {
|
|
228
|
+
attachmentPrompt = await materializeIncomingAttachment(msg);
|
|
229
|
+
} catch (err) {
|
|
230
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
231
|
+
console.error("[file] download failed:", err);
|
|
232
|
+
await replyText(client, msg.messageId, `下载附件失败:${message}`);
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const promptParts = [attachmentPrompt, text].filter(Boolean);
|
|
237
|
+
const prompt = promptParts.join("\n\n").trim();
|
|
238
|
+
|
|
239
|
+
if (!prompt) {
|
|
240
|
+
await replyText(
|
|
241
|
+
client,
|
|
242
|
+
msg.messageId,
|
|
243
|
+
"请发送文本、文件或图片;或发送 /new 开启新对话。",
|
|
244
|
+
);
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (isResetCommand(text)) {
|
|
249
|
+
resetSession(sessionStore, sessionKey);
|
|
250
|
+
pendingStore.delete(sessionKey);
|
|
251
|
+
await sessionQueue.clear(sessionKey);
|
|
252
|
+
await replyText(client, msg.messageId, "已开启新对话。直接发消息即可。");
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const pending = pendingStore.get(sessionKey);
|
|
257
|
+
if (pending && text && !attachmentPrompt) {
|
|
258
|
+
const answers = parseTextAnswer(pending.questions, text);
|
|
259
|
+
if (answers) {
|
|
260
|
+
await continueWithAnswers(sessionKey, msg.messageId, answers);
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
// Not a valid answer — treat as normal new prompt, drop pending.
|
|
264
|
+
console.log(`[ask] clearing pending; treating as new prompt session=${sessionKey}`);
|
|
265
|
+
pendingStore.delete(sessionKey);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
enqueuePrompt(sessionKey, msg.messageId, msg.chatId, prompt);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async function handleCardAction(data: unknown) {
|
|
272
|
+
const { value, chatId, messageId } = extractCardAction(data);
|
|
273
|
+
if (!value) {
|
|
274
|
+
console.warn("[card] ignore non-askq action", JSON.stringify(data)?.slice(0, 400));
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const sessionKey = value.sk;
|
|
279
|
+
const pending = pendingStore.get(sessionKey);
|
|
280
|
+
if (!pending) {
|
|
281
|
+
console.warn(`[card] no pending for session=${sessionKey}`);
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const replyTo = messageId || pending.replyToMessageId;
|
|
286
|
+
const q = pending.questions.find((qq) => qq.id === value.qid);
|
|
287
|
+
if (!q || !q.options.some((o) => o.id === value.oid)) {
|
|
288
|
+
await replyText(client, replyTo, "选项无效或已过期,请重新提问。");
|
|
289
|
+
pendingStore.delete(sessionKey);
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Button path is for single-question single-select cards.
|
|
294
|
+
await continueWithAnswers(sessionKey, replyTo, [
|
|
295
|
+
{ questionId: value.qid, selectedOptionIds: [value.oid] },
|
|
296
|
+
]);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async function main() {
|
|
300
|
+
console.log("[boot] feishu-cursor-bot starting");
|
|
301
|
+
console.log(`[boot] agent cwd=${config.agentCwd}`);
|
|
302
|
+
console.log(`[boot] agent dirs=${config.agentDirs.join(", ") || "(none)"}`);
|
|
303
|
+
console.log(`[boot] sandbox=${config.agentSandbox}`);
|
|
304
|
+
console.log(`[boot] model=${config.cursorModel}`);
|
|
305
|
+
console.log(`[boot] inbox=${config.inboxDir}`);
|
|
306
|
+
if (config.feishuDocsFolder) {
|
|
307
|
+
console.log(`[boot] docs folder=${config.feishuDocsFolder}`);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
try {
|
|
311
|
+
const policyPath = writeHookPolicy();
|
|
312
|
+
console.log(`[boot] hook policy=${policyPath}`);
|
|
313
|
+
} catch (err) {
|
|
314
|
+
console.warn("[boot] failed to write hook policy:", err);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
try {
|
|
318
|
+
const botOpenId = await getBotOpenId(client);
|
|
319
|
+
console.log(`[boot] bot open_id=${botOpenId}`);
|
|
320
|
+
} catch (err) {
|
|
321
|
+
console.warn("[boot] could not resolve bot open_id yet:", err);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
eventDispatcher = new Lark.EventDispatcher({}).register({
|
|
325
|
+
// Feishu SDK callback typings are loose; annotate explicitly.
|
|
326
|
+
"im.message.receive_v1": async (data: Parameters<typeof parseIncomingMessage>[0]) => {
|
|
327
|
+
void handleMessage(data).catch((err: unknown) => {
|
|
328
|
+
console.error("[ws] unhandled message error:", err);
|
|
329
|
+
});
|
|
330
|
+
},
|
|
331
|
+
"card.action.trigger": async (data: Record<string, unknown>) => {
|
|
332
|
+
// Must return within ~3s; agent continue runs in background.
|
|
333
|
+
void handleCardAction(data).catch((err: unknown) => {
|
|
334
|
+
console.error("[ws] unhandled card action error:", err);
|
|
335
|
+
});
|
|
336
|
+
return {
|
|
337
|
+
toast: {
|
|
338
|
+
type: "info",
|
|
339
|
+
content: "已收到选择,继续处理…",
|
|
340
|
+
},
|
|
341
|
+
};
|
|
342
|
+
},
|
|
343
|
+
} as Record<string, (data: never) => Promise<unknown>>);
|
|
344
|
+
|
|
345
|
+
wsClient.start({ eventDispatcher });
|
|
346
|
+
|
|
347
|
+
console.log("[boot] Feishu WebSocket long connection started");
|
|
348
|
+
console.log(
|
|
349
|
+
"[boot] Events: im.message.receive_v1 | Callbacks: card.action.trigger (long connection)",
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
main().catch((err) => {
|
|
354
|
+
console.error("[boot] fatal:", err);
|
|
355
|
+
process.exit(1);
|
|
356
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import type { AskQuestion } from "./ask-question.js";
|
|
4
|
+
|
|
5
|
+
export type PendingQuestion = {
|
|
6
|
+
agentId: string;
|
|
7
|
+
chatId: string;
|
|
8
|
+
replyToMessageId: string;
|
|
9
|
+
title?: string;
|
|
10
|
+
questions: AskQuestion[];
|
|
11
|
+
partialText?: string;
|
|
12
|
+
createdAt: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
type PendingMap = Record<string, PendingQuestion>;
|
|
16
|
+
|
|
17
|
+
export class PendingQuestionStore {
|
|
18
|
+
private data: PendingMap = {};
|
|
19
|
+
|
|
20
|
+
constructor(private readonly filePath: string) {
|
|
21
|
+
this.load();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
get(sessionKey: string): PendingQuestion | undefined {
|
|
25
|
+
return this.data[sessionKey];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
set(sessionKey: string, pending: PendingQuestion): void {
|
|
29
|
+
this.data[sessionKey] = pending;
|
|
30
|
+
this.save();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
delete(sessionKey: string): void {
|
|
34
|
+
if (!(sessionKey in this.data)) return;
|
|
35
|
+
delete this.data[sessionKey];
|
|
36
|
+
this.save();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
private load(): void {
|
|
40
|
+
try {
|
|
41
|
+
if (!fs.existsSync(this.filePath)) {
|
|
42
|
+
this.data = {};
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
this.data = JSON.parse(fs.readFileSync(this.filePath, "utf8")) as PendingMap;
|
|
46
|
+
} catch (err) {
|
|
47
|
+
console.warn("[pending] failed to load store:", err);
|
|
48
|
+
this.data = {};
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
private save(): void {
|
|
53
|
+
fs.mkdirSync(path.dirname(this.filePath), { recursive: true });
|
|
54
|
+
fs.writeFileSync(this.filePath, JSON.stringify(this.data, null, 2), "utf8");
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import type * as Lark from "@larksuiteoapi/node-sdk";
|
|
2
|
+
import {
|
|
3
|
+
REACTION_QUEUED,
|
|
4
|
+
REACTION_WORKING,
|
|
5
|
+
reactToMessage,
|
|
6
|
+
removeReaction,
|
|
7
|
+
} from "./feishu.js";
|
|
8
|
+
|
|
9
|
+
export type QueueJob = {
|
|
10
|
+
messageId: string;
|
|
11
|
+
chatId: string;
|
|
12
|
+
prompt: string;
|
|
13
|
+
queuedReactionId?: string;
|
|
14
|
+
workingReactionId?: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type SessionQueueManager = {
|
|
18
|
+
enqueue: (sessionKey: string, job: Omit<QueueJob, "queuedReactionId" | "workingReactionId">) => void;
|
|
19
|
+
clear: (sessionKey: string) => Promise<void>;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export function createSessionQueueManager(
|
|
23
|
+
client: Lark.Client,
|
|
24
|
+
runJob: (job: QueueJob) => Promise<void>,
|
|
25
|
+
): SessionQueueManager {
|
|
26
|
+
const queues = new Map<string, QueueJob[]>();
|
|
27
|
+
const running = new Set<string>();
|
|
28
|
+
|
|
29
|
+
function getQueue(sessionKey: string): QueueJob[] {
|
|
30
|
+
let queue = queues.get(sessionKey);
|
|
31
|
+
if (!queue) {
|
|
32
|
+
queue = [];
|
|
33
|
+
queues.set(sessionKey, queue);
|
|
34
|
+
}
|
|
35
|
+
return queue;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function markWorking(job: QueueJob): Promise<void> {
|
|
39
|
+
if (job.queuedReactionId) {
|
|
40
|
+
try {
|
|
41
|
+
await removeReaction(client, job.messageId, job.queuedReactionId);
|
|
42
|
+
} catch (err) {
|
|
43
|
+
console.warn("[queue] failed to remove queued reaction:", err);
|
|
44
|
+
}
|
|
45
|
+
job.queuedReactionId = undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
job.workingReactionId = await reactToMessage(
|
|
50
|
+
client,
|
|
51
|
+
job.messageId,
|
|
52
|
+
REACTION_WORKING,
|
|
53
|
+
);
|
|
54
|
+
} catch (err) {
|
|
55
|
+
console.warn("[queue] working reaction failed:", err);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function clearWorking(job: QueueJob): Promise<void> {
|
|
60
|
+
if (!job.workingReactionId) return;
|
|
61
|
+
try {
|
|
62
|
+
await removeReaction(client, job.messageId, job.workingReactionId);
|
|
63
|
+
} catch (err) {
|
|
64
|
+
console.warn("[queue] failed to remove working reaction:", err);
|
|
65
|
+
}
|
|
66
|
+
job.workingReactionId = undefined;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function pump(sessionKey: string): Promise<void> {
|
|
70
|
+
if (running.has(sessionKey)) return;
|
|
71
|
+
running.add(sessionKey);
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
while (true) {
|
|
75
|
+
const queue = getQueue(sessionKey);
|
|
76
|
+
const job = queue.shift();
|
|
77
|
+
if (!job) break;
|
|
78
|
+
|
|
79
|
+
await markWorking(job);
|
|
80
|
+
try {
|
|
81
|
+
await runJob(job);
|
|
82
|
+
} finally {
|
|
83
|
+
await clearWorking(job);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
} finally {
|
|
87
|
+
running.delete(sessionKey);
|
|
88
|
+
if (getQueue(sessionKey).length > 0) {
|
|
89
|
+
void pump(sessionKey);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
enqueue(sessionKey, job) {
|
|
96
|
+
void (async () => {
|
|
97
|
+
const queue = getQueue(sessionKey);
|
|
98
|
+
const busy = running.has(sessionKey) || queue.length > 0;
|
|
99
|
+
const fullJob: QueueJob = { ...job };
|
|
100
|
+
|
|
101
|
+
if (busy) {
|
|
102
|
+
try {
|
|
103
|
+
fullJob.queuedReactionId = await reactToMessage(
|
|
104
|
+
client,
|
|
105
|
+
job.messageId,
|
|
106
|
+
REACTION_QUEUED,
|
|
107
|
+
);
|
|
108
|
+
} catch (err) {
|
|
109
|
+
console.warn("[queue] queued reaction failed:", err);
|
|
110
|
+
}
|
|
111
|
+
console.log(
|
|
112
|
+
`[queue] enqueued session=${sessionKey} depth=${queue.length + 1}`,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
queue.push(fullJob);
|
|
117
|
+
void pump(sessionKey);
|
|
118
|
+
})().catch((err) => {
|
|
119
|
+
console.error(`[queue] enqueue failed session=${sessionKey}:`, err);
|
|
120
|
+
});
|
|
121
|
+
},
|
|
122
|
+
|
|
123
|
+
async clear(sessionKey) {
|
|
124
|
+
const queue = queues.get(sessionKey);
|
|
125
|
+
if (!queue) return;
|
|
126
|
+
|
|
127
|
+
for (const job of queue) {
|
|
128
|
+
if (!job.queuedReactionId) continue;
|
|
129
|
+
try {
|
|
130
|
+
await removeReaction(client, job.messageId, job.queuedReactionId);
|
|
131
|
+
} catch (err) {
|
|
132
|
+
console.warn("[queue] failed to clear queued reaction:", err);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
queue.length = 0;
|
|
137
|
+
console.log(`[queue] cleared pending jobs session=${sessionKey}`);
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
export type SessionRecord = {
|
|
5
|
+
agentId: string;
|
|
6
|
+
updatedAt: string;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
type SessionMap = Record<string, SessionRecord>;
|
|
10
|
+
|
|
11
|
+
export class SessionStore {
|
|
12
|
+
private data: SessionMap = {};
|
|
13
|
+
|
|
14
|
+
constructor(private readonly filePath: string) {
|
|
15
|
+
this.load();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
get(sessionKey: string): SessionRecord | undefined {
|
|
19
|
+
return this.data[sessionKey];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
set(sessionKey: string, agentId: string): void {
|
|
23
|
+
this.data[sessionKey] = {
|
|
24
|
+
agentId,
|
|
25
|
+
updatedAt: new Date().toISOString(),
|
|
26
|
+
};
|
|
27
|
+
this.save();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
delete(sessionKey: string): void {
|
|
31
|
+
if (!(sessionKey in this.data)) return;
|
|
32
|
+
delete this.data[sessionKey];
|
|
33
|
+
this.save();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
private load(): void {
|
|
37
|
+
try {
|
|
38
|
+
if (!fs.existsSync(this.filePath)) {
|
|
39
|
+
this.data = {};
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
const raw = fs.readFileSync(this.filePath, "utf8");
|
|
43
|
+
this.data = JSON.parse(raw) as SessionMap;
|
|
44
|
+
} catch (err) {
|
|
45
|
+
console.warn("[session] failed to load store, starting empty:", err);
|
|
46
|
+
this.data = {};
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
private save(): void {
|
|
51
|
+
fs.mkdirSync(path.dirname(this.filePath), { recursive: true });
|
|
52
|
+
fs.writeFileSync(this.filePath, JSON.stringify(this.data, null, 2), "utf8");
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { config } from "./config.js";
|
|
4
|
+
|
|
5
|
+
/** Write allowlist for Cursor hooks under AGENT_CWD. */
|
|
6
|
+
export function writeHookPolicy(): string {
|
|
7
|
+
const roots = [
|
|
8
|
+
config.agentCwd,
|
|
9
|
+
...config.agentDirs,
|
|
10
|
+
].map((p) => {
|
|
11
|
+
try {
|
|
12
|
+
return fs.realpathSync(p);
|
|
13
|
+
} catch {
|
|
14
|
+
return path.resolve(p);
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
for (const name of config.agentDirLinks) {
|
|
19
|
+
const link = path.join(config.agentCwd, name);
|
|
20
|
+
try {
|
|
21
|
+
roots.push(fs.realpathSync(link));
|
|
22
|
+
} catch {
|
|
23
|
+
// ignore missing links
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const policyPath = path.join(config.agentCwd, ".cursor", "hooks", "policy.json");
|
|
28
|
+
fs.mkdirSync(path.dirname(policyPath), { recursive: true });
|
|
29
|
+
const payload = {
|
|
30
|
+
updatedAt: new Date().toISOString(),
|
|
31
|
+
allowedRoots: [...new Set(roots)],
|
|
32
|
+
protectedWriteGlobs: [".cursor/rules", ".rules"],
|
|
33
|
+
};
|
|
34
|
+
fs.writeFileSync(policyPath, JSON.stringify(payload, null, 2), "utf8");
|
|
35
|
+
return policyPath;
|
|
36
|
+
}
|