@hwj123weijian/pi-feishu 0.11.2 → 0.11.3

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
@@ -163,6 +163,10 @@ pi -e D:\ai_study\pi-feishu
163
163
 
164
164
  已知限制:Pi 是单会话进程,群与群、群与私聊之间共享一个执行引擎,**跨聊天**的任务仍全局串行(排队提示会如实显示位数)。同一聊天内任务运行中继续发消息,会直接并入当前对话(steer),无需排队。远程命令作用于最近活跃的会话,`/model`、`/thinking`、`/compact` 建议在对应聊天的上一条消息之后紧接着发送。
165
165
 
166
+ ### 与本地使用共存
167
+
168
+ Pi 同时是你本地的 TUI 工具:为避免飞书消息打断你正在终端里跑的任务,插件在驱动 Pi 前会检测本地是否繁忙——繁忙时飞书消息会等待(收到提示"本地 Pi 正在执行任务"),超过 15 分钟仍繁忙则取消处理并通知你重发。同理,群会话的目录锚定依赖会话文件落盘(Pi 在首条回复后才写盘),新会话的锚定从下一条消息开始生效。若在本地 Pi 手动切换过会话,远程路由的会话控制会过期一次:按提示在本地执行 `/feishu status` 刷新即可恢复。
169
+
166
170
  未知命令会返回提示,不会发给 Pi。`/model` 的候选来自本地 Pi 的 scoped models(未配置时为全部已授权模型);`/model` 无参数时展示当前模型。注意:任何以 `/` 开头的消息都会先被当作命令解析,想发给 Pi 的提问请勿以 `/` 开头。
167
171
 
168
172
  ### 取消排队中的消息
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hwj123weijian/pi-feishu",
3
- "version": "0.11.2",
3
+ "version": "0.11.3",
4
4
  "description": "Feishu private-chat and managed-group bridge for Pi",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/controller.ts CHANGED
@@ -90,6 +90,20 @@ export interface FeishuControllerOptions {
90
90
  generateBindingCode?: () => string;
91
91
  /** 落盘去重记录的路径;默认与凭据同目录,测试注入临时路径。 */
92
92
  deduplicationPath?: string;
93
+ /** 本地忙闲轮询间隔(测试可调小)。 */
94
+ idlePollMs?: number;
95
+ /** 本地 Pi 持续繁忙多久后放弃处理(默认 15 分钟)。 */
96
+ localBusyTimeoutMs?: number;
97
+ /** 繁忙持续多久后给聊天发等待提示(默认 3 秒)。 */
98
+ busyNotifyAfterMs?: number;
99
+ }
100
+
101
+ const DEFAULT_IDLE_POLL_MS = 2000;
102
+ const DEFAULT_LOCAL_BUSY_TIMEOUT_MS = 15 * 60 * 1000;
103
+ const DEFAULT_BUSY_NOTIFY_AFTER_MS = 3000;
104
+
105
+ function sleep(ms: number): Promise<void> {
106
+ return new Promise((resolve) => setTimeout(resolve, ms));
93
107
  }
94
108
 
95
109
  export interface FeishuStartResult {
@@ -106,6 +120,11 @@ export class FeishuController {
106
120
  private readonly generateBindingCode: (() => string) | undefined;
107
121
  private readonly queue = new SerialMessageQueue();
108
122
  private readonly deduplicator: MessageDeduplicator;
123
+ private readonly idlePollMs: number;
124
+ private readonly localBusyTimeoutMs: number;
125
+ private readonly busyNotifyAfterMs: number;
126
+ /** stop() 置位:让本地忙闲等待循环立即退出。 */
127
+ private stopped = false;
109
128
  private gateway: FeishuGateway | undefined;
110
129
  private credentials: FeishuCredentials | undefined;
111
130
  private binding: OwnerBinding | undefined;
@@ -122,6 +141,9 @@ export class FeishuController {
122
141
  this.agent = options.agent;
123
142
  this.runtime = options.runtime;
124
143
  this.generateBindingCode = options.generateBindingCode;
144
+ this.idlePollMs = options.idlePollMs ?? DEFAULT_IDLE_POLL_MS;
145
+ this.localBusyTimeoutMs = options.localBusyTimeoutMs ?? DEFAULT_LOCAL_BUSY_TIMEOUT_MS;
146
+ this.busyNotifyAfterMs = options.busyNotifyAfterMs ?? DEFAULT_BUSY_NOTIFY_AFTER_MS;
125
147
  // 对齐 easycodeclient:受理即落盘的去重记录,进程重启后飞书重推也不会重复执行。
126
148
  this.deduplicator = new MessageDeduplicator(5000, options.deduplicationPath ?? defaultProcessedMessagesPath());
127
149
  }
@@ -170,6 +192,7 @@ export class FeishuController {
170
192
  this.binding = binding;
171
193
  this.gateway = gateway;
172
194
  this.environment = environment;
195
+ this.stopped = false;
173
196
  // 恢复落盘的受理记录:重启后飞书重推的事件仍会被去重,而不是重复驱动 Pi。
174
197
  await this.deduplicator.hydrate();
175
198
 
@@ -250,6 +273,13 @@ export class FeishuController {
250
273
  return Object.values(sessions).includes(sessionFile);
251
274
  }
252
275
 
276
+ /** 向指定聊天发一条提示文字(fire-and-forget,失败静默)。 */
277
+ async notifyChat(chatId: string, text: string): Promise<void> {
278
+ const gateway = this.gateway;
279
+ if (!gateway) return;
280
+ await gateway.sendText(chatId, text).catch(() => undefined);
281
+ }
282
+
253
283
  async setChatSessionFile(chatId: string, sessionFile: string): Promise<void> {
254
284
  const credentials = this.credentials;
255
285
  if (!credentials) return;
@@ -286,6 +316,7 @@ export class FeishuController {
286
316
  async stop(): Promise<boolean> {
287
317
  const gateway = this.gateway;
288
318
  if (!gateway) return false;
319
+ this.stopped = true;
289
320
  this.botAddedUnsubscribe?.();
290
321
  this.botAddedUnsubscribe = undefined;
291
322
  for (const entry of this.pendingTasks.values()) entry.cancelled = true;
@@ -835,8 +866,50 @@ export class FeishuController {
835
866
  this.credentials = next;
836
867
  }
837
868
 
869
+ /**
870
+ * 等待本地 Pi 空闲。超过 localBusyTimeoutMs 仍繁忙则放弃(返回 false),
871
+ * 等待超过 3 秒时先给聊天发一条提示,让用户知道消息没有丢。
872
+ */
873
+ private async waitForLocalIdle(gateway: FeishuGateway, message: FeishuIncomingMessage): Promise<boolean> {
874
+ const runtime = this.runtime;
875
+ if (!runtime) return true;
876
+ const start = Date.now();
877
+ let notified = false;
878
+ for (;;) {
879
+ if (this.stopped || this.gateway !== gateway) return false;
880
+ let idle: boolean;
881
+ try {
882
+ idle = runtime.isIdle();
883
+ } catch {
884
+ idle = true; // 忙闲未知(ctx 失效等)时不得阻塞消息处理。
885
+ }
886
+ if (idle) return true;
887
+ const waitedMs = Date.now() - start;
888
+ if (waitedMs > this.localBusyTimeoutMs) return false;
889
+ if (!notified && waitedMs > this.busyNotifyAfterMs) {
890
+ notified = true;
891
+ await gateway
892
+ .sendText(
893
+ message.chatId,
894
+ "⏳ 本地 Pi 正在执行任务,本条消息将在其完成后处理(不会打断本地任务)。",
895
+ message.messageId,
896
+ )
897
+ .catch(() => undefined);
898
+ }
899
+ await sleep(this.idlePollMs);
900
+ }
901
+ }
902
+
838
903
  private async processWithAgent(gateway: FeishuGateway, message: FeishuIncomingMessage, text: string): Promise<void> {
839
904
  if (this.gateway !== gateway) return;
905
+ // Pi 是单会话进程:本地 TUI 正在跑的任务会被会话切换 abort 掉。
906
+ // 因此驱动 Pi 之前先等本地空闲,绝不打断用户手头的工作。
907
+ if (!(await this.waitForLocalIdle(gateway, message))) {
908
+ await gateway
909
+ .sendText(message.chatId, "⏳ 本地 Pi 长时间繁忙,本条消息已取消处理,请稍后重发。", message.messageId)
910
+ .catch(() => undefined);
911
+ return;
912
+ }
840
913
  const reply = await gateway.beginReply(message.chatId, message.messageId).catch(() => undefined);
841
914
  if (reply) this.activeReplies.add(reply);
842
915
  let latestText = "";
package/src/extension.ts CHANGED
@@ -241,16 +241,24 @@ async function sendToChatSession(state: SharedFeishuState, chatId: string, promp
241
241
  if (sessionFile) {
242
242
  // Pi 的 TUI switchSession 不透传 cwdOverride,改为把 cwd 写进会话头:
243
243
  // SessionManager.open 切换时从 header 读取 cwd,runtime 随之在新目录重建。
244
- if (directory && !(await anchorSessionHeaderCwd(sessionFile, directory))) {
245
- throw new Error(`无法把群会话锚定到 ${directory}(会话文件不可写或格式未知)。`);
244
+ // 会话文件可能尚未落盘(Pi 要等首条 assistant 回复才写文件)——此时跳过
245
+ // 锚定正常处理,本轮回复落盘后下一轮自动生效,绝不因锚定失败终止消息。
246
+ let anchored = true;
247
+ if (directory) {
248
+ anchored = await anchorSessionHeaderCwd(sessionFile, directory);
246
249
  }
247
250
  const result = await context.switchSession(sessionFile, {
248
251
  withSession: (nextContext) => {
249
- if (directory) assertAnchoredCwd(nextContext, directory);
252
+ if (directory && anchored) checkAnchoredCwd(state, chatId, nextContext, directory);
250
253
  return sendAndTrackGroupSession(state, chatId, sessionFile, nextContext, prompt);
251
254
  },
252
255
  });
253
256
  if (result.cancelled) throw new Error("切换到飞书群绑定的 Pi 会话已取消。");
257
+ if (directory && !anchored) {
258
+ await state.controller
259
+ .notifyChat(chatId, "ℹ️ 本群的目录锚定将在下一条消息生效(当前会话文件尚未写入磁盘)。")
260
+ .catch(() => undefined);
261
+ }
254
262
  return;
255
263
  }
256
264
 
@@ -259,21 +267,12 @@ async function sendToChatSession(state: SharedFeishuState, chatId: string, promp
259
267
  state.latestCommandContext = nextContext;
260
268
  const currentFile = nextContext.sessionManager.getSessionFile();
261
269
  if (currentFile) await state.controller.setChatSessionFile(chatId, currentFile);
262
- if (currentFile && directory) {
263
- // 新会话默认继承当前 cwd:把会话头 cwd 改写为绑定目录后重新切换一次,
264
- // runtime 会以新 cwd 重建,然后才投递消息。
265
- if (!(await anchorSessionHeaderCwd(currentFile, directory))) {
266
- throw new Error(`无法把群会话锚定到 ${directory}(会话文件不可写或格式未知)。`);
267
- }
268
- const anchored = await nextContext.switchSession(currentFile, {
269
- withSession: (finalContext) => {
270
- state.latestCommandContext = finalContext;
271
- assertAnchoredCwd(finalContext, directory);
272
- return finalContext.sendUserMessage(prompt);
273
- },
274
- });
275
- if (anchored.cancelled) throw new Error("锚定群会话工作目录已取消。");
276
- return;
270
+ if (directory) {
271
+ // 全新会话的文件还没落盘(Pi 等首条 assistant 回复才写):本轮先在
272
+ // 继承的目录下处理,回复落盘后下一轮消息到来时锚定自动生效。
273
+ await state.controller
274
+ .notifyChat(chatId, `ℹ️ 本群已锚定到目录:${directory},将从下一条消息开始在该目录下工作。`)
275
+ .catch(() => undefined);
277
276
  }
278
277
  await nextContext.sendUserMessage(prompt);
279
278
  },
@@ -320,12 +319,18 @@ export async function anchorSessionHeaderCwd(sessionFile: string, directory: str
320
319
  return true;
321
320
  }
322
321
 
323
- function assertAnchoredCwd(context: ExtensionContext, directory: string): void {
322
+ /** 锚定后校验 runtime cwd 是否真的切了过去;不符只提示,不终止消息。 */
323
+ function checkAnchoredCwd(
324
+ state: SharedFeishuState,
325
+ chatId: string,
326
+ context: ExtensionContext,
327
+ directory: string,
328
+ ): void {
324
329
  const effective = safeCwd(context);
325
330
  if (effective && resolvePath(effective) !== resolvePath(directory)) {
326
- throw new Error(
327
- `群会话未能切换到绑定目录(当前:${effective})。请重启本地 Pi 后重试,或在本地执行 /feishu status 刷新会话控制。`,
328
- );
331
+ void state.controller
332
+ .notifyChat(chatId, `⚠️ 群会话未能切换到绑定目录(当前:${effective},期望:${directory})。`)
333
+ .catch(() => undefined);
329
334
  }
330
335
  }
331
336