@chenglu.she/sandy 1.0.5 → 1.0.6

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/README.md CHANGED
@@ -115,7 +115,7 @@ bash scripts/sandy-ctl.sh logs
115
115
  | 同会话 | `Agent.resume` 多轮;映射在 cwd 的 `.data/sessions.json` |
116
116
  | `/new` `/reset` `重置` `新对话` | 清空会话,下次新建 Agent |
117
117
  | 连续消息 | 按会话排队;排队 `OneSecond`,处理中 `OnIt` |
118
- | `askQuestion` | 飞书交互卡片;点选或回编号后续跑 |
118
+ | `askQuestion` | `feishu_ask_question`:飞书先发题目正文,再发可点选卡片;点选或回编号后续跑 |
119
119
  | 用户发文件/图 | 下载到 `AGENT_CWD/.data/feishu-inbox/…`,路径写入 prompt |
120
120
  | Agent 发回文件 | 工具 `feishu_send_file`(本地路径 → 飞书回复) |
121
121
  | 飞书文档 | `feishu_doc_read` / `feishu_doc_create` / `feishu_doc_append` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chenglu.she/sandy",
3
- "version": "1.0.5",
3
+ "version": "1.0.6",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "description": "Feishu long-connection bot that drives Cursor agents via @cursor/sdk",
@@ -23,28 +23,41 @@ function asRecord(value: unknown): Record<string, unknown> | undefined {
23
23
  }
24
24
 
25
25
  function parseOptions(raw: unknown): AskOption[] {
26
- if (!Array.isArray(raw)) return [];
27
- return raw
28
- .map((item, index) => {
29
- const obj = asRecord(item);
30
- if (!obj) return undefined;
31
- const id =
32
- typeof obj.id === "string"
33
- ? obj.id
34
- : typeof obj.value === "string"
35
- ? obj.value
36
- : `opt_${index + 1}`;
37
- const label =
38
- typeof obj.label === "string"
39
- ? obj.label
40
- : typeof obj.text === "string"
41
- ? obj.text
42
- : typeof obj.title === "string"
43
- ? obj.title
44
- : String(id);
45
- return { id, label };
46
- })
47
- .filter((x): x is AskOption => Boolean(x));
26
+ if (Array.isArray(raw)) {
27
+ return raw
28
+ .map((item, index) => {
29
+ if (typeof item === "string" && item.trim()) {
30
+ return { id: `opt_${index + 1}`, label: item.trim() };
31
+ }
32
+ const obj = asRecord(item);
33
+ if (!obj) return undefined;
34
+ const id =
35
+ typeof obj.id === "string"
36
+ ? obj.id
37
+ : typeof obj.value === "string"
38
+ ? obj.value
39
+ : `opt_${index + 1}`;
40
+ const label =
41
+ typeof obj.label === "string"
42
+ ? obj.label
43
+ : typeof obj.text === "string"
44
+ ? obj.text
45
+ : typeof obj.title === "string"
46
+ ? obj.title
47
+ : typeof obj.description === "string"
48
+ ? obj.description
49
+ : String(id);
50
+ return { id, label };
51
+ })
52
+ .filter((x): x is AskOption => Boolean(x));
53
+ }
54
+
55
+ const obj = asRecord(raw);
56
+ if (!obj) return [];
57
+ return Object.entries(obj).map(([id, label], index) => ({
58
+ id: id || `opt_${index + 1}`,
59
+ label: typeof label === "string" && label.trim() ? label : String(id),
60
+ }));
48
61
  }
49
62
 
50
63
  function parseQuestions(raw: unknown): AskQuestion[] {
@@ -66,9 +79,10 @@ function parseQuestions(raw: unknown): AskQuestion[] {
66
79
  ? obj.question
67
80
  : typeof obj.text === "string"
68
81
  ? obj.text
69
- : `问题 ${index + 1}`;
82
+ : typeof obj.header === "string"
83
+ ? obj.header
84
+ : `问题 ${index + 1}`;
70
85
  const options = parseOptions(obj.options ?? obj.choices);
71
- if (options.length === 0) return undefined;
72
86
  return {
73
87
  id,
74
88
  prompt,
@@ -81,7 +95,7 @@ function parseQuestions(raw: unknown): AskQuestion[] {
81
95
 
82
96
  /** Defensive parse of askQuestion tool args / tool_use input. */
83
97
  export function parseAskQuestionArgs(args: unknown): ParsedAskQuestion | undefined {
84
- const root = asRecord(args);
98
+ const root = asRecord(coerceJson(args));
85
99
  if (!root) return undefined;
86
100
 
87
101
  const nested =
@@ -91,22 +105,22 @@ export function parseAskQuestionArgs(args: unknown): ParsedAskQuestion | undefin
91
105
  root;
92
106
 
93
107
  const questions = parseQuestions(
94
- nested.questions ?? nested.question_list ?? nested.items,
108
+ coerceJson(nested.questions ?? nested.question_list ?? nested.items),
95
109
  );
96
110
  if (questions.length === 0) {
97
- // Single-question flattened shape
98
111
  const options = parseOptions(nested.options ?? nested.choices);
99
- if (options.length > 0) {
112
+ const prompt =
113
+ typeof nested.prompt === "string"
114
+ ? nested.prompt
115
+ : typeof nested.question === "string"
116
+ ? nested.question
117
+ : typeof nested.title === "string"
118
+ ? nested.title
119
+ : "";
120
+ if (options.length > 0 || prompt) {
100
121
  questions.push({
101
122
  id: typeof nested.id === "string" ? nested.id : "q1",
102
- prompt:
103
- typeof nested.prompt === "string"
104
- ? nested.prompt
105
- : typeof nested.question === "string"
106
- ? nested.question
107
- : typeof nested.title === "string"
108
- ? nested.title
109
- : "请选择",
123
+ prompt: prompt || "请选择",
110
124
  options,
111
125
  allowMultiple: Boolean(nested.allowMultiple ?? nested.allow_multiple),
112
126
  });
@@ -121,10 +135,49 @@ export function parseAskQuestionArgs(args: unknown): ParsedAskQuestion | undefin
121
135
  };
122
136
  }
123
137
 
124
- export function isAskQuestionToolName(name: string | undefined): boolean {
138
+ function coerceJson(raw: unknown): unknown {
139
+ if (typeof raw !== "string") return raw;
140
+ const trimmed = raw.trim();
141
+ if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return raw;
142
+ try {
143
+ return JSON.parse(trimmed);
144
+ } catch {
145
+ return raw;
146
+ }
147
+ }
148
+
149
+ /** Built-in Cursor AskQuestion only — not our Feishu custom tool. */
150
+ export function isBuiltinAskQuestionToolName(name: string | undefined): boolean {
125
151
  if (!name) return false;
126
152
  const n = name.replace(/[_-]/g, "").toLowerCase();
127
- return n === "askquestion" || n.endsWith("askquestion");
153
+ return n === "askquestion";
154
+ }
155
+
156
+ export function isAskQuestionToolName(name: string | undefined): boolean {
157
+ return isBuiltinAskQuestionToolName(name);
158
+ }
159
+
160
+ /** Last-resort ask payload when the tool fired but args did not parse. */
161
+ export function fallbackAskFromRaw(args: unknown): ParsedAskQuestion {
162
+ const parsed = parseAskQuestionArgs(args);
163
+ if (parsed) return parsed;
164
+ const dump =
165
+ typeof args === "string"
166
+ ? args
167
+ : args == null
168
+ ? ""
169
+ : JSON.stringify(args, null, 2);
170
+ return {
171
+ title: "需要你的回复",
172
+ questions: [
173
+ {
174
+ id: "q1",
175
+ prompt: dump.trim() || "Agent 发起了询问,但题目解析失败。请直接回复你的选择或补充说明。",
176
+ options: [],
177
+ allowMultiple: false,
178
+ },
179
+ ],
180
+ };
128
181
  }
129
182
 
130
183
  /** Format selected answers as a follow-up user message for Agent.send. */
@@ -0,0 +1,44 @@
1
+ export type AskAnswer = {
2
+ questionId: string;
3
+ selectedOptionIds: string[];
4
+ freeformText?: string;
5
+ };
6
+
7
+ type Waiter = {
8
+ resolve: (answers: AskAnswer[]) => void;
9
+ reject: (err: Error) => void;
10
+ };
11
+
12
+ const waiters = new Map<string, Waiter>();
13
+
14
+ export function hasAskWaiter(sessionKey: string): boolean {
15
+ return waiters.has(sessionKey);
16
+ }
17
+
18
+ export function waitForAskAnswers(sessionKey: string): Promise<AskAnswer[]> {
19
+ const existing = waiters.get(sessionKey);
20
+ if (existing) {
21
+ existing.reject(new Error("replaced by a new question round"));
22
+ waiters.delete(sessionKey);
23
+ }
24
+
25
+ return new Promise((resolve, reject) => {
26
+ waiters.set(sessionKey, { resolve, reject });
27
+ });
28
+ }
29
+
30
+ export function resolveAskWaiter(sessionKey: string, answers: AskAnswer[]): boolean {
31
+ const waiter = waiters.get(sessionKey);
32
+ if (!waiter) return false;
33
+ waiters.delete(sessionKey);
34
+ waiter.resolve(answers);
35
+ return true;
36
+ }
37
+
38
+ export function cancelAskWaiter(sessionKey: string, reason: string): boolean {
39
+ const waiter = waiters.get(sessionKey);
40
+ if (!waiter) return false;
41
+ waiters.delete(sessionKey);
42
+ waiter.reject(new Error(reason));
43
+ return true;
44
+ }
@@ -1,6 +1,7 @@
1
1
  import { Agent, CursorAgentError, type SDKCustomTool } from "@cursor/sdk";
2
2
  import {
3
- isAskQuestionToolName,
3
+ fallbackAskFromRaw,
4
+ isBuiltinAskQuestionToolName,
4
5
  parseAskQuestionArgs,
5
6
  type ParsedAskQuestion,
6
7
  } from "./ask-question.js";
@@ -45,29 +46,24 @@ export async function runCursorAgent(
45
46
  const existing = sessionStore.get(sessionKey);
46
47
  let agent;
47
48
  const customTools = options?.customTools;
49
+ const agentOptions = {
50
+ apiKey: config.cursorApiKey,
51
+ model: { id: config.cursorModel },
52
+ name: config.agentName,
53
+ // Built-in AskQuestion is auto-declined in the SDK and never reaches Feishu.
54
+ disallowedTools: ["askQuestion"],
55
+ local: {
56
+ ...localAgentOptions(),
57
+ ...(customTools ? { customTools } : {}),
58
+ },
59
+ };
48
60
 
49
61
  try {
50
62
  if (existing?.agentId) {
51
- agent = await Agent.resume(existing.agentId, {
52
- apiKey: config.cursorApiKey,
53
- model: { id: config.cursorModel },
54
- name: config.agentName,
55
- local: {
56
- ...localAgentOptions(),
57
- ...(customTools ? { customTools } : {}),
58
- },
59
- });
63
+ agent = await Agent.resume(existing.agentId, agentOptions);
60
64
  console.log(`[cursor] resumed agent=${existing.agentId} session=${sessionKey}`);
61
65
  } else {
62
- agent = await Agent.create({
63
- apiKey: config.cursorApiKey,
64
- model: { id: config.cursorModel },
65
- name: config.agentName,
66
- local: {
67
- ...localAgentOptions(),
68
- ...(customTools ? { customTools } : {}),
69
- },
70
- });
66
+ agent = await Agent.create(agentOptions);
71
67
  sessionStore.set(sessionKey, agent.agentId);
72
68
  console.log(`[cursor] created agent=${agent.agentId} session=${sessionKey}`);
73
69
  }
@@ -88,31 +84,26 @@ export async function runCursorAgent(
88
84
  if (block.type === "text" && block.text) {
89
85
  partialText += block.text;
90
86
  }
91
- if (block.type === "tool_use" && isAskQuestionToolName(block.name)) {
92
- const parsed = parseAskQuestionArgs(block.input);
93
- if (parsed) {
94
- pendingAsk = parsed;
95
- sawAskQuestion = true;
96
- console.log(
97
- `[cursor] askQuestion via tool_use questions=${parsed.questions.length}`,
98
- );
99
- }
87
+ if (block.type === "tool_use" && isBuiltinAskQuestionToolName(block.name)) {
88
+ pendingAsk = parseAskQuestionArgs(block.input) ?? fallbackAskFromRaw(block.input);
89
+ sawAskQuestion = true;
90
+ console.log(
91
+ `[cursor] askQuestion via tool_use questions=${pendingAsk.questions.length}`,
92
+ );
100
93
  }
101
94
  }
102
95
  }
103
96
 
104
- if (event.type === "tool_call" && isAskQuestionToolName(event.name)) {
105
- const parsed = parseAskQuestionArgs(event.args);
106
- if (parsed) {
107
- pendingAsk = parsed;
108
- sawAskQuestion = true;
109
- console.log(
110
- `[cursor] askQuestion tool_call status=${event.status} questions=${parsed.questions.length}`,
111
- );
112
- } else {
97
+ if (event.type === "tool_call" && isBuiltinAskQuestionToolName(event.name)) {
98
+ pendingAsk = parseAskQuestionArgs(event.args) ?? fallbackAskFromRaw(event.args);
99
+ sawAskQuestion = true;
100
+ console.log(
101
+ `[cursor] askQuestion tool_call status=${event.status} questions=${pendingAsk.questions.length}`,
102
+ );
103
+ if (!parseAskQuestionArgs(event.args)) {
113
104
  console.warn(
114
- "[cursor] askQuestion tool_call with unparsable args:",
115
- JSON.stringify(event.args)?.slice(0, 500),
105
+ "[cursor] askQuestion args used fallback:",
106
+ JSON.stringify(event.args)?.slice(0, 800),
116
107
  );
117
108
  }
118
109
 
@@ -3,6 +3,12 @@ import path from "node:path";
3
3
  import type * as Lark from "@larksuiteoapi/node-sdk";
4
4
  import type { SDKCustomTool, SDKJsonValue } from "@cursor/sdk";
5
5
  import { config } from "./config.js";
6
+ import {
7
+ fallbackAskFromRaw,
8
+ parseAskQuestionArgs,
9
+ type ParsedAskQuestion,
10
+ } from "./ask-question.js";
11
+ import type { AskAnswer } from "./ask-waiters.js";
6
12
  import {
7
13
  appendMarkdownToDocument,
8
14
  createDocument,
@@ -35,6 +41,7 @@ export type FeishuToolContext = {
35
41
  client: Lark.Client;
36
42
  replyToMessageId: string;
37
43
  chatId: string;
44
+ onAskQuestion?: (ask: ParsedAskQuestion) => Promise<AskAnswer[]>;
38
45
  };
39
46
 
40
47
  /** In-process tools exposed to the Cursor agent as custom-user-tools. */
@@ -42,6 +49,71 @@ export function buildFeishuCustomTools(
42
49
  ctx: FeishuToolContext,
43
50
  ): Record<string, SDKCustomTool> {
44
51
  return {
52
+ feishu_ask_question: {
53
+ description:
54
+ "Ask the Feishu user one or more questions and wait until they reply. " +
55
+ "Always use this when you need a decision (auth, image, plan, env, etc.). " +
56
+ "The user cannot see Cursor's AskQuestion UI — never write “等你回上面的题” " +
57
+ "without calling this tool. Include the full prompt and options on every question.",
58
+ inputSchema: {
59
+ type: "object",
60
+ properties: {
61
+ title: { type: "string", description: "Optional card title" },
62
+ questions: {
63
+ type: "array",
64
+ description: "Questions to show in Feishu",
65
+ items: {
66
+ type: "object",
67
+ properties: {
68
+ id: { type: "string" },
69
+ prompt: { type: "string", description: "Question text shown to the user" },
70
+ options: {
71
+ type: "array",
72
+ items: {
73
+ type: "object",
74
+ properties: {
75
+ id: { type: "string" },
76
+ label: { type: "string" },
77
+ },
78
+ },
79
+ },
80
+ allowMultiple: { type: "boolean" },
81
+ },
82
+ required: ["prompt"],
83
+ },
84
+ },
85
+ },
86
+ required: ["questions"],
87
+ },
88
+ async execute(args) {
89
+ try {
90
+ if (!ctx.onAskQuestion) {
91
+ throw new Error("onAskQuestion is not configured");
92
+ }
93
+ const parsed =
94
+ parseAskQuestionArgs(args) ?? fallbackAskFromRaw(args);
95
+ const answers = await ctx.onAskQuestion(parsed);
96
+ return {
97
+ content: [
98
+ {
99
+ type: "text",
100
+ text: JSON.stringify({ ok: true, answers }),
101
+ },
102
+ ],
103
+ };
104
+ } catch (err) {
105
+ return {
106
+ content: [
107
+ {
108
+ type: "text",
109
+ text: `feishu_ask_question cancelled: ${err instanceof Error ? err.message : String(err)}`,
110
+ },
111
+ ],
112
+ isError: true,
113
+ };
114
+ }
115
+ },
116
+ },
45
117
  feishu_send_file: {
46
118
  description:
47
119
  "Send a local file or image to the current Feishu chat as a reply. " +
package/src/feishu.ts CHANGED
@@ -287,7 +287,7 @@ export function buildAskQuestionCard(
287
287
  } satisfies CardActionValue,
288
288
  })),
289
289
  });
290
- } else {
290
+ } else if (q.options.length > 0) {
291
291
  const lines = q.options.map((opt, oi) => `${oi + 1}. ${opt.label}`);
292
292
  elements.push({
293
293
  tag: "markdown",
@@ -297,12 +297,14 @@ export function buildAskQuestionCard(
297
297
  }
298
298
 
299
299
  if (!useButtons) {
300
+ const hasOptions = ask.questions.some((q) => q.options.length > 0);
300
301
  elements.push({
301
302
  tag: "markdown",
302
- content:
303
- ask.questions.length === 1
303
+ content: hasOptions
304
+ ? ask.questions.length === 1
304
305
  ? "请直接回复选项编号(多选如 `1,3`),或回复选项原文。"
305
- : "请按题号回复,例如:`1:2; 2:1`(题号:选项编号)。",
306
+ : "请按题号回复,例如:`1:2; 2:1`(题号:选项编号)。也可直接用文字说明。"
307
+ : "请直接回复本题答案。",
306
308
  });
307
309
  }
308
310
 
@@ -318,19 +320,26 @@ export function buildAskQuestionCard(
318
320
  }
319
321
 
320
322
  export function formatAskQuestionFallbackText(ask: ParsedAskQuestion): string {
321
- const lines = [`【需要选择】${ask.title?.trim() || ""}`.trim()];
323
+ const lines = [`【需要你选择】${ask.title?.trim() || ""}`.trim()];
322
324
  for (const [qi, q] of ask.questions.entries()) {
323
325
  lines.push("");
324
326
  lines.push(`${qi + 1}. ${q.prompt}${q.allowMultiple ? "(可多选)" : ""}`);
327
+ if (q.options.length === 0) {
328
+ lines.push(" (请直接回复)");
329
+ continue;
330
+ }
325
331
  for (const [oi, opt] of q.options.entries()) {
326
332
  lines.push(` ${oi + 1}) ${opt.label}`);
327
333
  }
328
334
  }
329
335
  lines.push("");
336
+ const hasOptions = ask.questions.some((q) => q.options.length > 0);
330
337
  lines.push(
331
- ask.questions.length === 1
332
- ? "回复编号继续(多选如 1,3),也可点卡片按钮。"
333
- : "回复格式如 1:2; 2:1,也可点卡片按钮(单题单选)。",
338
+ hasOptions
339
+ ? ask.questions.length === 1
340
+ ? "回复编号继续(多选如 1,3),也可点卡片按钮。"
341
+ : "回复格式如 1:2; 2:1,或直接用文字说明。"
342
+ : "请直接回复答案。",
334
343
  );
335
344
  return lines.join("\n");
336
345
  }
@@ -341,6 +350,13 @@ export async function replyAskQuestionCard(
341
350
  sessionKey: string,
342
351
  ask: ParsedAskQuestion,
343
352
  ): Promise<void> {
353
+ // Always send readable text first. Cursor's built-in AskQuestion UI never
354
+ // reaches Feishu; if the interactive card fails, the user still sees the
355
+ // questions and how to reply.
356
+ await replyText(client, messageId, formatAskQuestionFallbackText(ask), {
357
+ preferMarkdown: true,
358
+ });
359
+
344
360
  const card = buildAskQuestionCard(sessionKey, ask);
345
361
  try {
346
362
  await client.im.v1.message.reply({
@@ -351,8 +367,7 @@ export async function replyAskQuestionCard(
351
367
  },
352
368
  });
353
369
  } catch (err) {
354
- console.warn("[feishu] interactive card failed, falling back to text:", err);
355
- await replyText(client, messageId, formatAskQuestionFallbackText(ask));
370
+ console.warn("[feishu] interactive ask card failed (text already sent):", err);
356
371
  }
357
372
  }
358
373
 
package/src/index.ts CHANGED
@@ -2,7 +2,15 @@ import path from "node:path";
2
2
  import {
3
3
  formatAnswerPrompt,
4
4
  parseTextAnswer,
5
+ type AskQuestion,
5
6
  } from "./ask-question.js";
7
+ import {
8
+ cancelAskWaiter,
9
+ hasAskWaiter,
10
+ resolveAskWaiter,
11
+ waitForAskAnswers,
12
+ type AskAnswer,
13
+ } from "./ask-waiters.js";
6
14
  import {
7
15
  isResetCommand,
8
16
  resetSession,
@@ -138,10 +146,6 @@ async function deliverOutcome(
138
146
  createdAt: new Date().toISOString(),
139
147
  });
140
148
 
141
- if (outcome.partialText) {
142
- await replyAgentText(client, replyToMessageId, outcome.partialText);
143
- }
144
-
145
149
  await replyAskQuestionCard(client, replyToMessageId, sessionKey, outcome.ask);
146
150
  console.log(
147
151
  `[ask] waiting for selection session=${sessionKey} questions=${outcome.ask.questions.length}`,
@@ -160,12 +164,32 @@ const sessionQueue = createSessionQueueManager(client, async (job: QueueJob) =>
160
164
  client,
161
165
  replyToMessageId: job.messageId,
162
166
  chatId: job.chatId,
167
+ onAskQuestion: async (ask) => {
168
+ pendingStore.set(sessionKey, {
169
+ agentId: sessionStore.get(sessionKey)?.agentId ?? "",
170
+ chatId: job.chatId,
171
+ replyToMessageId: job.messageId,
172
+ title: ask.title,
173
+ questions: ask.questions,
174
+ createdAt: new Date().toISOString(),
175
+ });
176
+ await replyAskQuestionCard(client, job.messageId, sessionKey, ask);
177
+ console.log(
178
+ `[ask] waiting (feishu_ask_question) session=${sessionKey} questions=${ask.questions.length}`,
179
+ );
180
+ try {
181
+ return await waitForAskAnswers(sessionKey);
182
+ } finally {
183
+ pendingStore.delete(sessionKey);
184
+ }
185
+ },
163
186
  });
164
187
  const outcome = await runCursorAgent(sessionStore, sessionKey, job.prompt, {
165
188
  customTools,
166
189
  });
167
190
  await deliverOutcome(sessionKey, job.messageId, job.chatId, outcome);
168
191
  } catch (err) {
192
+ cancelAskWaiter(sessionKey, "agent failed");
169
193
  const message = err instanceof Error ? err.message : String(err);
170
194
  console.error("[handle] agent failed:", err);
171
195
  await replyText(client, job.messageId, `处理失败:${message}`);
@@ -185,11 +209,24 @@ function enqueuePrompt(
185
209
  });
186
210
  }
187
211
 
212
+ function freeformAnswers(questions: AskQuestion[], text: string): AskAnswer[] {
213
+ return questions.map((q) => ({
214
+ questionId: q.id,
215
+ selectedOptionIds: [],
216
+ freeformText: text,
217
+ }));
218
+ }
219
+
188
220
  async function continueWithAnswers(
189
221
  sessionKey: string,
190
222
  replyToMessageId: string,
191
- answers: Array<{ questionId: string; selectedOptionIds: string[]; freeformText?: string }>,
223
+ answers: AskAnswer[],
192
224
  ): Promise<void> {
225
+ if (resolveAskWaiter(sessionKey, answers)) {
226
+ console.log(`[ask] resolved in-run waiter session=${sessionKey}`);
227
+ return;
228
+ }
229
+
193
230
  const pending = pendingStore.get(sessionKey);
194
231
  if (!pending) {
195
232
  await replyText(client, replyToMessageId, "没有待回答的选择题,直接发消息即可。");
@@ -251,6 +288,7 @@ async function handleMessage(raw: Parameters<typeof parseIncomingMessage>[0]) {
251
288
  }
252
289
 
253
290
  if (isResetCommand(text)) {
291
+ cancelAskWaiter(sessionKey, "user reset");
254
292
  resetSession(sessionStore, sessionKey);
255
293
  pendingStore.delete(sessionKey);
256
294
  await sessionQueue.clear(sessionKey);
@@ -260,12 +298,13 @@ async function handleMessage(raw: Parameters<typeof parseIncomingMessage>[0]) {
260
298
 
261
299
  const pending = pendingStore.get(sessionKey);
262
300
  if (pending && text && !attachmentPrompt) {
263
- const answers = parseTextAnswer(pending.questions, text);
301
+ const answers =
302
+ parseTextAnswer(pending.questions, text) ??
303
+ (hasAskWaiter(sessionKey) ? freeformAnswers(pending.questions, text) : undefined);
264
304
  if (answers) {
265
305
  await continueWithAnswers(sessionKey, msg.messageId, answers);
266
306
  return;
267
307
  }
268
- // Not a valid answer — treat as normal new prompt, drop pending.
269
308
  console.log(`[ask] clearing pending; treating as new prompt session=${sessionKey}`);
270
309
  pendingStore.delete(sessionKey);
271
310
  }
@@ -291,6 +330,7 @@ async function handleCardAction(data: unknown) {
291
330
  const q = pending.questions.find((qq) => qq.id === value.qid);
292
331
  if (!q || !q.options.some((o) => o.id === value.oid)) {
293
332
  await replyText(client, replyTo, "选项无效或已过期,请重新提问。");
333
+ cancelAskWaiter(sessionKey, "invalid option");
294
334
  pendingStore.delete(sessionKey);
295
335
  return;
296
336
  }
@@ -10,7 +10,7 @@ alwaysApply: true
10
10
  ## 职责
11
11
 
12
12
  - 协助写代码、排障、运维与部署
13
- - 在飞书里回答简洁、可执行;需要选择时用选择题
13
+ - 在飞书里回答简洁、可执行;需要对方做选择时,必须调用 `feishu_ask_question`(题目和选项会发到飞书)。不要只写「等你回上面的题」——飞书用户看不到 Cursor 里的询问框。
14
14
  - 可在 `AGENT_CWD/.cursor/rules/` 下创建或更新规则,沉淀领域约定与协作习惯
15
15
  - 文件修改范围以 `config.yaml` 里的 `agent.cwd` / `agent.dirs` 白名单为准
16
16
  - 可使用本机 shell(SSH / git / 项目脚本),除非 `agent.sandbox=true`