@hwj123weijian/pi-feishu 0.10.2 → 0.11.1

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
@@ -155,12 +155,13 @@ pi -e D:\ai_study\pi-feishu
155
155
  ### 群聊使用
156
156
 
157
157
  - **绑定已有群**:把机器人加进现有项目群后,Owner 在群里发送 `/bind` 即可绑定;之后该群获得独立 Pi session,与其他群和主对话互不串上下文。机器人被拉进新群时也会私聊 Owner 提醒绑定。
158
+ - **绑定项目目录**(推荐):发送 `/bind /path/to/project` 把群锚定到项目目录——群会话的工作目录会切换到该目录,bot 直接读写这个项目;Owner 显式绑定的目录会自动加入 Pi 的项目信任。私聊里说“帮我拉个群 /path/to/project”也可以在建群时一并锚定。
158
159
  - **触发方式**:默认在群里 `@机器人 你的问题`。飞书只向 bot 推送 @bot 的群消息;普通消息直接触发需要敏感权限 `im:message.group_msg`。
159
160
  - **授权成员**:默认只有 Owner 能使用。Owner 私聊发送 `/allow @张三` 后,张三即可在私聊和已绑定群里与机器人协作;`/deny @张三` 撤销。
160
161
  - **分寸感**:未绑定的群里被 @ 时回复绑定引导;非授权成员被 @ 时回复一次“未授权”;其余群内消息完全静默,不会刷屏。
161
162
  - **说话人上下文**:群消息发给 Pi 前会加上 `[飞书群聊] 说话人:` 前缀,@ 其他人会被改写成 `@名字`,模型知道在和谁对话。
162
163
 
163
- 已知限制:Pi 是单会话进程,多个聊天的任务仍全局串行(排队提示会如实显示位数);远程命令作用于最近活跃的会话,`/model`、`/thinking`、`/compact` 建议在对应聊天的上一条消息之后紧接着发送。
164
+ 已知限制:Pi 是单会话进程,群与群、群与私聊之间共享一个执行引擎,**跨聊天**的任务仍全局串行(排队提示会如实显示位数)。同一聊天内任务运行中继续发消息,会直接并入当前对话(steer),无需排队。远程命令作用于最近活跃的会话,`/model`、`/thinking`、`/compact` 建议在对应聊天的上一条消息之后紧接着发送。
164
165
 
165
166
  未知命令会返回提示,不会发给 Pi。`/model` 的候选来自本地 Pi 的 scoped models(未配置时为全部已授权模型);`/model` 无参数时展示当前模型。注意:任何以 `/` 开头的消息都会先被当作命令解析,想发给 Pi 的提问请勿以 `/` 开头。
166
167
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hwj123weijian/pi-feishu",
3
- "version": "0.10.2",
3
+ "version": "0.11.1",
4
4
  "description": "Feishu private-chat and managed-group bridge for Pi",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/contracts.ts CHANGED
@@ -9,6 +9,8 @@ export interface FeishuCredentials {
9
9
  allowlist?: string[];
10
10
  /** Display names for allowlisted open IDs, captured from @mentions when authorizing. */
11
11
  allowlistNames?: Record<string, string>;
12
+ /** Per managed group working directory (easycodeclient-style project binding). */
13
+ groupDirs?: Record<string, string>;
12
14
  }
13
15
 
14
16
  export interface CredentialStore {
@@ -153,6 +155,11 @@ export interface AgentRunOptions {
153
155
  export interface AgentBridge {
154
156
  run(text: string, observer?: AgentProgressObserver, options?: AgentRunOptions): Promise<string>;
155
157
  cancel(reason?: string): void;
158
+ /**
159
+ * 并入当前正在运行的轮次(同聊天追加消息时使用)。
160
+ * 仅当确有轮次在跑时返回 true;false 表示当前空闲,调用方应回退到正常排队。
161
+ */
162
+ steer?(text: string): boolean;
156
163
  }
157
164
 
158
165
  export type FeishuGatewayFactory = (credentials: FeishuCredentials) => FeishuGateway;
package/src/controller.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { stat } from "node:fs/promises";
2
+ import { resolve } from "node:path";
1
3
  import type {
2
4
  AgentBridge,
3
5
  CredentialStore,
@@ -202,17 +204,36 @@ export class FeishuController {
202
204
  await gateway.sendText(ownerOpenId, welcome).catch(() => undefined);
203
205
  }
204
206
 
205
- async createGroupChat(name: string): Promise<string> {
207
+ async createGroupChat(name: string, directory?: string): Promise<string> {
206
208
  const gateway = this.gateway;
207
209
  const ownerOpenId = this.credentials?.ownerOpenId;
208
210
  if (!gateway) throw new CredentialError("飞书尚未连接,请先执行 /feishu start。");
209
211
  if (!ownerOpenId) throw new CredentialError("飞书尚未绑定 Owner,无法创建群聊。");
210
212
  const chatId = await gateway.createGroupChat(name, ownerOpenId);
211
213
  this.managedGroupIds.add(chatId);
212
- await this.saveCredentials({ managedGroupIds: [...this.managedGroupIds] });
214
+ let anchoredLine = "";
215
+ if (directory) {
216
+ const resolved = resolve(directory);
217
+ let isDirectory = false;
218
+ try {
219
+ isDirectory = (await stat(resolved)).isDirectory();
220
+ } catch {
221
+ isDirectory = false;
222
+ }
223
+ if (isDirectory) {
224
+ await this.saveCredentials({
225
+ managedGroupIds: [...this.managedGroupIds],
226
+ groupDirs: { ...(this.credentials?.groupDirs ?? {}), [chatId]: resolved },
227
+ });
228
+ anchoredLine = `\n📂 本群已锚定到目录:${resolved}`;
229
+ }
230
+ }
231
+ if (!this.credentials?.groupDirs?.[chatId]) {
232
+ await this.saveCredentials({ managedGroupIds: [...this.managedGroupIds] });
233
+ }
213
234
  await gateway.sendText(
214
235
  chatId,
215
- `👋 群聊「${name}」已创建,当前绑定的 Pi 飞书机器人已就绪。直接在群内 @机器人即可开始协作。若群内普通消息没有响应,请先 @ 机器人;开通免 @ 权限后可直接发消息。`,
236
+ `👋 群聊「${name}」已创建,当前绑定的 Pi 飞书机器人已就绪。直接在群内 @机器人即可开始协作。若群内普通消息没有响应,请先 @ 机器人;开通免 @ 权限后可直接发消息。${anchoredLine}`,
216
237
  );
217
238
  await this.sendGroupPermissionReminder(gateway, ownerOpenId, name);
218
239
  return chatId;
@@ -379,9 +400,37 @@ export class FeishuController {
379
400
  await this.runRemoteCommand(gateway, message, reactionId, authorizedText);
380
401
  return;
381
402
  }
403
+ // 对齐 easycodeclient 的 mid-turn 注入:同聊天的追加消息直接并入正在运行的轮次,
404
+ // 不排队(跨聊天仍全局串行——Pi 是单会话进程)。
405
+ if (this.trySteerIntoRunningTurn(gateway, message, reactionId, authorizedText)) return;
382
406
  void this.enqueueAgentTask(gateway, message, authorizedText, reactionId);
383
407
  }
384
408
 
409
+ /** 同聊天已有任务在跑时,把新消息并入该轮次;返回是否成功并入。 */
410
+ private trySteerIntoRunningTurn(
411
+ gateway: FeishuGateway,
412
+ message: FeishuIncomingMessage,
413
+ reactionId: string,
414
+ text: string,
415
+ ): boolean {
416
+ const runningTask = [...this.pendingTasks.values()].find(
417
+ (entry) => entry.started && !entry.cancelled && entry.chatId === message.chatId,
418
+ );
419
+ if (!runningTask) return false;
420
+ const steered = this.agent.steer?.(buildAgentPrompt(message, text)) ?? false;
421
+ if (!steered) return false;
422
+ // 并入后由运行中的任务统一回复;清掉本条消息的已读表情,改用文字确认。
423
+ void this.clearRead(gateway, message.messageId, reactionId);
424
+ void gateway
425
+ .sendText(
426
+ message.chatId,
427
+ "✅ 已收到,已并入当前正在处理的对话;回复会更新在上面的回复卡片里。",
428
+ message.messageId,
429
+ )
430
+ .catch(() => undefined);
431
+ return true;
432
+ }
433
+
385
434
  private isAllowlisted(senderOpenId: string): boolean {
386
435
  return this.credentials?.allowlist?.includes(senderOpenId) ?? false;
387
436
  }
@@ -423,19 +472,28 @@ export class FeishuController {
423
472
  await gateway
424
473
  .sendText(
425
474
  message.chatId,
426
- "本群还没有绑定到 Pi。Owner 在群里发送 /bind 即可绑定本群:之后群内消息会进入该群专属的独立 Pi 会话。",
475
+ "本群还没有绑定到 Pi。Owner 在群里发送 /bind 即可绑定本群;也可带目录把群锚定到项目:/bind /path/to/project。",
427
476
  message.messageId,
428
477
  )
429
478
  .catch(() => undefined);
430
479
  }
431
480
 
432
- /** 把当前群登记为受管群并持久化。 */
481
+ /** 把当前群登记为受管群并持久化;可同时绑定工作目录(对齐 easycodeclient 的 /bind <路径>)。 */
433
482
  private async bindManagedGroup(gateway: FeishuGateway, message: FeishuIncomingMessage): Promise<void> {
483
+ const command = parseRemoteCommand(message.text);
484
+ const dirArg = command?.args.trim();
485
+ if (dirArg) {
486
+ await this.bindGroupDirectory(gateway, message, dirArg);
487
+ return;
488
+ }
434
489
  if (this.managedGroupIds.has(message.chatId)) {
490
+ const dir = this.getChatDirectory(message.chatId);
435
491
  await gateway
436
492
  .sendText(
437
493
  message.chatId,
438
- "本群已绑定到 Pi,直接 @机器人 即可提问(开通免 @ 权限后无需 @)。",
494
+ dir
495
+ ? `本群已绑定到 Pi(工作目录:${dir})。直接 @机器人 即可提问(开通免 @ 权限后无需 @)。`
496
+ : "本群已绑定到 Pi,直接 @机器人 即可提问(开通免 @ 权限后无需 @)。\n如需把本群锚定到项目目录,发送:/bind /path/to/project",
439
497
  message.messageId,
440
498
  )
441
499
  .catch(() => undefined);
@@ -446,12 +504,55 @@ export class FeishuController {
446
504
  await gateway
447
505
  .sendText(
448
506
  message.chatId,
449
- "✅ 本群已绑定到 Pi。下一条群消息会自动创建该群专属的独立 Pi 会话;默认需要 @机器人 触发。",
507
+ "✅ 本群已绑定到 Pi。下一条群消息会自动创建该群专属的独立 Pi 会话;默认需要 @机器人 触发。\n💡 如需让本群在指定项目目录下工作,发送:/bind /path/to/project",
508
+ message.messageId,
509
+ )
510
+ .catch(() => undefined);
511
+ }
512
+
513
+ /** /bind <目录>:校验目录存在后,把群锚定到该目录(工作目录在会话切换时生效)。 */
514
+ private async bindGroupDirectory(
515
+ gateway: FeishuGateway,
516
+ message: FeishuIncomingMessage,
517
+ dirArg: string,
518
+ ): Promise<void> {
519
+ const resolved = resolve(dirArg);
520
+ let isDirectory = false;
521
+ try {
522
+ isDirectory = (await stat(resolved)).isDirectory();
523
+ } catch {
524
+ isDirectory = false;
525
+ }
526
+ if (!isDirectory) {
527
+ await gateway
528
+ .sendText(message.chatId, `❌ 目录不存在或不是文件夹:${resolved}\n用法:/bind /path/to/project`, message.messageId)
529
+ .catch(() => undefined);
530
+ return;
531
+ }
532
+ this.managedGroupIds.add(message.chatId);
533
+ await this.saveCredentials({
534
+ managedGroupIds: [...this.managedGroupIds],
535
+ groupDirs: { ...(this.credentials?.groupDirs ?? {}), [message.chatId]: resolved },
536
+ });
537
+ await gateway
538
+ .sendText(
539
+ message.chatId,
540
+ `✅ 本群已锚定到目录:${resolved}\n下一条群消息会在该目录下的独立 Pi 会话中处理(已有会话也会切换工作目录)。`,
450
541
  message.messageId,
451
542
  )
452
543
  .catch(() => undefined);
453
544
  }
454
545
 
546
+ /** 群绑定的项目目录;未绑定的群返回 undefined(沿用当前 cwd)。 */
547
+ getChatDirectory(chatId: string): string | undefined {
548
+ return this.credentials?.groupDirs?.[chatId];
549
+ }
550
+
551
+ /** 所有已绑定的群目录(用于 project trust 自动放行)。 */
552
+ getBoundDirectories(): string[] {
553
+ return Object.values(this.credentials?.groupDirs ?? {});
554
+ }
555
+
455
556
  /** Controller 自己处理的命令;返回 false 时回落到通用远程命令通道。 */
456
557
  private async runManagedChatCommand(
457
558
  gateway: FeishuGateway,
@@ -70,6 +70,7 @@ export function resolveRuntimeCredentials(
70
70
  ...(matching?.groupSessions ? { groupSessions: matching.groupSessions } : {}),
71
71
  ...(matching?.allowlist ? { allowlist: matching.allowlist } : {}),
72
72
  ...(matching?.allowlistNames ? { allowlistNames: matching.allowlistNames } : {}),
73
+ ...(matching?.groupDirs ? { groupDirs: matching.groupDirs } : {}),
73
74
  };
74
75
  }
75
76
  return stored;
@@ -146,6 +147,7 @@ export class FileCredentialStore implements CredentialStore {
146
147
  ? { allowlist: value.allowlist.filter((id): id is string => typeof id === "string") }
147
148
  : {}),
148
149
  ...(isStringRecord(value.allowlistNames) ? { allowlistNames: value.allowlistNames } : {}),
150
+ ...(isStringRecord(value.groupDirs) ? { groupDirs: value.groupDirs } : {}),
149
151
  };
150
152
  }
151
153
 
@@ -185,7 +187,8 @@ function isCredentialRecord(value: unknown): value is FeishuCredentials {
185
187
  (record.managedGroupIds === undefined || Array.isArray(record.managedGroupIds)) &&
186
188
  (record.groupSessions === undefined || isStringRecord(record.groupSessions)) &&
187
189
  (record.allowlist === undefined || Array.isArray(record.allowlist)) &&
188
- (record.allowlistNames === undefined || isStringRecord(record.allowlistNames))
190
+ (record.allowlistNames === undefined || isStringRecord(record.allowlistNames)) &&
191
+ (record.groupDirs === undefined || isStringRecord(record.groupDirs))
189
192
  );
190
193
  }
191
194
 
package/src/extension.ts CHANGED
@@ -1,6 +1,22 @@
1
- import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
1
+ import { readFile, writeFile } from "node:fs/promises";
2
+ import { resolve as resolvePath } from "node:path";
3
+ import type {
4
+ ExtensionAPI,
5
+ ExtensionCommandContext,
6
+ ExtensionContext,
7
+ ProjectTrustHandler,
8
+ } from "@earendil-works/pi-coding-agent";
2
9
  import { Type } from "typebox";
3
10
  import type { AgentBridge, FeishuStatus, PiModelInfo, PiRuntime, PiRuntimeSnapshot } from "./contracts.js";
11
+
12
+ /** switchSession/withSession 回调里的新会话上下文;Pi 未从包根导出该类型名,从签名提取。 */
13
+ type SwitchCallback = NonNullable<
14
+ Parameters<ExtensionCommandContext["switchSession"]>[1] extends infer O | undefined
15
+ ? O extends { withSession?: (ctx: infer C) => unknown }
16
+ ? C
17
+ : never
18
+ : never
19
+ >;
4
20
  import { FeishuController } from "./controller.js";
5
21
  import { CredentialError, FileCredentialStore } from "./credentials.js";
6
22
  import { SdkFeishuGateway, validateSdkCredentials } from "./gateway.js";
@@ -30,7 +46,7 @@ interface SharedFeishuState {
30
46
  current: SessionLink | undefined;
31
47
  activeBridge: PiAgentBridge | undefined;
32
48
  latestCommandContext: ExtensionCommandContext | undefined;
33
- sendCurrentMessage: ((text: string) => void) | undefined;
49
+ sendCurrentMessage: ((text: string, options?: { deliverAs?: "steer" | "followUp" }) => void) | undefined;
34
50
  /** Pi 会话文件:p2p 私聊消息归属的主会话(绝不会是群绑定会话)。 */
35
51
  mainSessionFile: string | undefined;
36
52
  /** Pi 进程当前所在的会话文件,随每个事件刷新。 */
@@ -81,6 +97,12 @@ function getSharedState(): SharedFeishuState {
81
97
  });
82
98
  },
83
99
  cancel: (reason) => state.activeBridge?.cancel(reason),
100
+ steer: (text) => {
101
+ // 仅当确有飞书轮次在跑时并入:发到当前活跃会话(轮次就在那里),绝不触发会话切换。
102
+ if (!state.activeBridge || !state.sendCurrentMessage) return false;
103
+ state.sendCurrentMessage(text, { deliverAs: "steer" });
104
+ return true;
105
+ },
84
106
  };
85
107
  const proxyRuntime: PiRuntime = {
86
108
  isIdle: () => state.current?.runtime.isIdle() ?? true,
@@ -180,17 +202,20 @@ async function sendToChatSession(state: SharedFeishuState, chatId: string, promp
180
202
  const context = state.latestCommandContext;
181
203
  if (!context) throw new Error("Pi 会话控制尚未就绪,请先在本地执行一次 /feishu status。");
182
204
 
205
+ const directory = state.controller.getChatDirectory(chatId);
183
206
  state.switchingSession = true;
184
207
  try {
185
208
  const sessionFile = state.controller.getChatSessionFile(chatId);
186
209
  if (sessionFile) {
210
+ // Pi 的 TUI switchSession 不透传 cwdOverride,改为把 cwd 写进会话头:
211
+ // SessionManager.open 切换时从 header 读取 cwd,runtime 随之在新目录重建。
212
+ if (directory && !(await anchorSessionHeaderCwd(sessionFile, directory))) {
213
+ throw new Error(`无法把群会话锚定到 ${directory}(会话文件不可写或格式未知)。`);
214
+ }
187
215
  const result = await context.switchSession(sessionFile, {
188
- withSession: async (nextContext) => {
189
- state.latestCommandContext = nextContext;
190
- const currentFile = nextContext.sessionManager.getSessionFile();
191
- if (currentFile && currentFile !== sessionFile)
192
- await state.controller.setChatSessionFile(chatId, currentFile);
193
- await nextContext.sendUserMessage(prompt);
216
+ withSession: (nextContext) => {
217
+ if (directory) assertAnchoredCwd(nextContext, directory);
218
+ return sendAndTrackGroupSession(state, chatId, sessionFile, nextContext, prompt);
194
219
  },
195
220
  });
196
221
  if (result.cancelled) throw new Error("切换到飞书群绑定的 Pi 会话已取消。");
@@ -202,6 +227,22 @@ async function sendToChatSession(state: SharedFeishuState, chatId: string, promp
202
227
  state.latestCommandContext = nextContext;
203
228
  const currentFile = nextContext.sessionManager.getSessionFile();
204
229
  if (currentFile) await state.controller.setChatSessionFile(chatId, currentFile);
230
+ if (currentFile && directory) {
231
+ // 新会话默认继承当前 cwd:把会话头 cwd 改写为绑定目录后重新切换一次,
232
+ // runtime 会以新 cwd 重建,然后才投递消息。
233
+ if (!(await anchorSessionHeaderCwd(currentFile, directory))) {
234
+ throw new Error(`无法把群会话锚定到 ${directory}(会话文件不可写或格式未知)。`);
235
+ }
236
+ const anchored = await nextContext.switchSession(currentFile, {
237
+ withSession: (finalContext) => {
238
+ state.latestCommandContext = finalContext;
239
+ assertAnchoredCwd(finalContext, directory);
240
+ return finalContext.sendUserMessage(prompt);
241
+ },
242
+ });
243
+ if (anchored.cancelled) throw new Error("锚定群会话工作目录已取消。");
244
+ return;
245
+ }
205
246
  await nextContext.sendUserMessage(prompt);
206
247
  },
207
248
  });
@@ -211,6 +252,70 @@ async function sendToChatSession(state: SharedFeishuState, chatId: string, promp
211
252
  }
212
253
  }
213
254
 
255
+ /**
256
+ * 把会话文件头(JSONL 首行的 session header)里的 cwd 改写为绑定目录。
257
+ * 群会话空闲时文件不被任何 runtime 持有,重写首行是安全的;下次
258
+ * SessionManager.open 会以新 cwd 重建 runtime(工具、bash 都在新目录执行)。
259
+ * 返回 false 表示文件不可读/格式未知,调用方给出可操作错误。
260
+ */
261
+ export async function anchorSessionHeaderCwd(sessionFile: string, directory: string): Promise<boolean> {
262
+ let raw: string;
263
+ try {
264
+ raw = await readFile(sessionFile, "utf8");
265
+ } catch {
266
+ return false;
267
+ }
268
+ const newlineIndex = raw.indexOf("\n");
269
+ const firstLine = newlineIndex === -1 ? raw : raw.slice(0, newlineIndex);
270
+ let header: Record<string, unknown>;
271
+ try {
272
+ header = JSON.parse(firstLine) as Record<string, unknown>;
273
+ } catch {
274
+ return false;
275
+ }
276
+ if (header.type !== "session" || typeof header.cwd !== "string") return false;
277
+ if (resolvePath(header.cwd) === resolvePath(directory)) return true;
278
+ header.cwd = directory;
279
+ const updatedFirstLine = JSON.stringify(header);
280
+ const updated = newlineIndex === -1 ? updatedFirstLine : updatedFirstLine + raw.slice(newlineIndex);
281
+ try {
282
+ await writeFile(sessionFile, updated, "utf8");
283
+ } catch {
284
+ return false;
285
+ }
286
+ return true;
287
+ }
288
+
289
+ function assertAnchoredCwd(context: ExtensionContext, directory: string): void {
290
+ const effective = safeCwd(context);
291
+ if (effective && resolvePath(effective) !== resolvePath(directory)) {
292
+ throw new Error(
293
+ `群会话未能切换到绑定目录(当前:${effective})。请重启本地 Pi 后重试,或在本地执行 /feishu status 刷新会话控制。`,
294
+ );
295
+ }
296
+ }
297
+
298
+ function safeCwd(context: ExtensionContext): string | undefined {
299
+ try {
300
+ return context.cwd;
301
+ } catch {
302
+ return undefined;
303
+ }
304
+ }
305
+
306
+ async function sendAndTrackGroupSession(
307
+ state: SharedFeishuState,
308
+ chatId: string,
309
+ sessionFile: string,
310
+ nextContext: SwitchCallback,
311
+ prompt: string,
312
+ ): Promise<void> {
313
+ state.latestCommandContext = nextContext;
314
+ const currentFile = nextContext.sessionManager.getSessionFile();
315
+ if (currentFile && currentFile !== sessionFile) await state.controller.setChatSessionFile(chatId, currentFile);
316
+ await nextContext.sendUserMessage(prompt);
317
+ }
318
+
214
319
  export interface ParsedFeishuCommand {
215
320
  name: FeishuCommandName;
216
321
  args: string;
@@ -386,7 +491,7 @@ export function renderFeishuStatus(status: FeishuStatus, scopeHealth?: string[])
386
491
 
387
492
  export default function feishuExtension(pi: ExtensionAPI): void {
388
493
  const state = getSharedState();
389
- state.sendCurrentMessage = (text) => pi.sendUserMessage(text);
494
+ state.sendCurrentMessage = (text, options) => pi.sendUserMessage(text, options);
390
495
  let latestContext: ExtensionContext | undefined;
391
496
  let latestCommandContext: ExtensionCommandContext | undefined;
392
497
 
@@ -505,25 +610,42 @@ export default function feishuExtension(pi: ExtensionAPI): void {
505
610
  state.activeBridge?.cancel("Pi 会话已关闭。");
506
611
  });
507
612
 
613
+ // 对齐 easycodeclient:Owner 通过 /bind 或建群显式绑定的目录视为受信项目,
614
+ // 免去切换群会话时的本地信任弹窗。undecided = 不表态,Pi 落回默认流程
615
+ //(runner 对 undecided 的 handler 会跳过);handler 用带注解的变量传入,
616
+ // 内联箭头会让 on() 的重载推断失败。
617
+ const onProjectTrust: ProjectTrustHandler = (event) => {
618
+ if (state.controller.getBoundDirectories().includes(event.cwd)) {
619
+ return { trusted: "yes", remember: true };
620
+ }
621
+ return { trusted: "undecided" };
622
+ };
623
+ pi.on("project_trust", onProjectTrust);
624
+
508
625
  pi.registerTool({
509
626
  name: "feishu_create_group",
510
627
  label: "Create Feishu Group",
511
628
  description:
512
- "Create a Feishu group chat and invite the currently bound owner. Use when asked to 拉群、建群、创建飞书群 or create a group. This creates only the group (not a local project directory).",
629
+ "Create a Feishu group chat and invite the currently bound owner. Use when asked to 拉群、建群、创建飞书群 or create a group. Pass `path` when the user mentions a directory (e.g. 拉个群 /path/to/project) to anchor the group's sessions to that working directory; the directory must already exist.",
513
630
  parameters: Type.Object({
514
631
  name: Type.String({ description: "Name for the new Feishu group chat" }),
632
+ path: Type.Optional(
633
+ Type.String({ description: "Absolute path of an existing local directory to anchor the group to" }),
634
+ ),
515
635
  }),
516
636
  async execute(_toolCallId, params) {
637
+ const name = params.name.trim();
517
638
  try {
518
- const chatId = await state.controller.createGroupChat(params.name.trim());
639
+ const chatId = await state.controller.createGroupChat(name, params.path?.trim() || undefined);
640
+ const anchored = params.path?.trim() ? `,并锚定到目录 ${params.path.trim()}` : "";
519
641
  return {
520
642
  content: [
521
643
  {
522
644
  type: "text",
523
- text: `群聊「${params.name.trim()}」已创建,群聊 ID:${chatId}。已邀请当前绑定的飞书用户,并发送群欢迎消息。`,
645
+ text: `群聊「${name}」已创建,群聊 ID:${chatId}${anchored}。已邀请当前绑定的飞书用户,并发送群欢迎消息。`,
524
646
  },
525
647
  ],
526
- details: { chatId, name: params.name.trim() },
648
+ details: { chatId, name },
527
649
  };
528
650
  } catch (error) {
529
651
  return {