@hwj123weijian/pi-feishu 0.11.1 → 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.1",
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
@@ -100,8 +100,13 @@ function getSharedState(): SharedFeishuState {
100
100
  steer: (text) => {
101
101
  // 仅当确有飞书轮次在跑时并入:发到当前活跃会话(轮次就在那里),绝不触发会话切换。
102
102
  if (!state.activeBridge || !state.sendCurrentMessage) return false;
103
- state.sendCurrentMessage(text, { deliverAs: "steer" });
104
- return true;
103
+ try {
104
+ state.sendCurrentMessage(text, { deliverAs: "steer" });
105
+ return true;
106
+ } catch {
107
+ // pi 已失效等场景:并入失败,回落到正常排队。
108
+ return false;
109
+ }
105
110
  },
106
111
  };
107
112
  const proxyRuntime: PiRuntime = {
@@ -122,6 +127,8 @@ function getSharedState(): SharedFeishuState {
122
127
  },
123
128
  });
124
129
  return !result.cancelled;
130
+ } catch (error) {
131
+ rethrowStaleAsHint(state, error);
125
132
  } finally {
126
133
  state.switchingSession = false;
127
134
  }
@@ -165,6 +172,24 @@ function tryGetSessionFile(context: ExtensionContext): string | undefined {
165
172
  }
166
173
  }
167
174
 
175
+ /** 缓存的命令上下文已失效(Pi 侧发生过会话替换)时的可操作提示。 */
176
+ export const SESSION_CONTROL_HINT =
177
+ "本地 Pi 会话控制已过期(本地发生过会话切换)。请在本地 Pi 执行一次 /feishu status 刷新,然后重发这条消息。";
178
+
179
+ function isStaleCtxError(error: unknown): boolean {
180
+ const message = error instanceof Error ? error.message : String(error);
181
+ return message.includes("extension ctx is stale") || message.includes("ctx is stale after session replacement");
182
+ }
183
+
184
+ /** 把 Pi 的英文 stale 内部错误转译成可操作指引,其余错误原样上抛。 */
185
+ function rethrowStaleAsHint(state: SharedFeishuState, error: unknown): never {
186
+ if (isStaleCtxError(error)) {
187
+ state.latestCommandContext = undefined;
188
+ throw new Error(SESSION_CONTROL_HINT);
189
+ }
190
+ throw error instanceof Error ? error : new Error(String(error));
191
+ }
192
+
168
193
  /**
169
194
  * p2p 私聊消息永远落在主会话:群消息处理会把 Pi 切进群的会话,
170
195
  * 这里负责在下一条私聊到来时切回去,避免把 Owner 的提问发进群会话。
@@ -182,7 +207,7 @@ async function sendToMainSession(state: SharedFeishuState, prompt: string): Prom
182
207
  const context = state.latestCommandContext;
183
208
  if (!context) {
184
209
  // 主会话与当前会话不一致却没有命令上下文:直发会串进群会话,给出可操作的错误更安全。
185
- throw new Error("Pi 会话控制尚未就绪,请先在本地执行一次 /feishu status。");
210
+ throw new Error(SESSION_CONTROL_HINT);
186
211
  }
187
212
  state.switchingSession = true;
188
213
  try {
@@ -193,32 +218,47 @@ async function sendToMainSession(state: SharedFeishuState, prompt: string): Prom
193
218
  },
194
219
  });
195
220
  if (result.cancelled) throw new Error("切回主 Pi 会话已取消。");
221
+ } catch (error) {
222
+ rethrowStaleAsHint(state, error);
196
223
  } finally {
197
224
  state.switchingSession = false;
198
225
  }
199
226
  }
200
227
 
201
228
  async function sendToChatSession(state: SharedFeishuState, chatId: string, prompt: string): Promise<void> {
229
+ const sessionFile = state.controller.getChatSessionFile(chatId);
230
+ // Pi 侧切换后当前会话可能恰好就是目标群会话:此时无需命令上下文,直接投递。
231
+ if (sessionFile && sessionFile === state.currentSessionFile && state.sendCurrentMessage) {
232
+ state.sendCurrentMessage(prompt);
233
+ return;
234
+ }
202
235
  const context = state.latestCommandContext;
203
- if (!context) throw new Error("Pi 会话控制尚未就绪,请先在本地执行一次 /feishu status。");
236
+ if (!context) throw new Error(SESSION_CONTROL_HINT);
204
237
 
205
238
  const directory = state.controller.getChatDirectory(chatId);
206
239
  state.switchingSession = true;
207
240
  try {
208
- const sessionFile = state.controller.getChatSessionFile(chatId);
209
241
  if (sessionFile) {
210
242
  // Pi 的 TUI switchSession 不透传 cwdOverride,改为把 cwd 写进会话头:
211
243
  // SessionManager.open 切换时从 header 读取 cwd,runtime 随之在新目录重建。
212
- if (directory && !(await anchorSessionHeaderCwd(sessionFile, directory))) {
213
- throw new Error(`无法把群会话锚定到 ${directory}(会话文件不可写或格式未知)。`);
244
+ // 会话文件可能尚未落盘(Pi 要等首条 assistant 回复才写文件)——此时跳过
245
+ // 锚定正常处理,本轮回复落盘后下一轮自动生效,绝不因锚定失败终止消息。
246
+ let anchored = true;
247
+ if (directory) {
248
+ anchored = await anchorSessionHeaderCwd(sessionFile, directory);
214
249
  }
215
250
  const result = await context.switchSession(sessionFile, {
216
251
  withSession: (nextContext) => {
217
- if (directory) assertAnchoredCwd(nextContext, directory);
252
+ if (directory && anchored) checkAnchoredCwd(state, chatId, nextContext, directory);
218
253
  return sendAndTrackGroupSession(state, chatId, sessionFile, nextContext, prompt);
219
254
  },
220
255
  });
221
256
  if (result.cancelled) throw new Error("切换到飞书群绑定的 Pi 会话已取消。");
257
+ if (directory && !anchored) {
258
+ await state.controller
259
+ .notifyChat(chatId, "ℹ️ 本群的目录锚定将在下一条消息生效(当前会话文件尚未写入磁盘)。")
260
+ .catch(() => undefined);
261
+ }
222
262
  return;
223
263
  }
224
264
 
@@ -227,26 +267,19 @@ async function sendToChatSession(state: SharedFeishuState, chatId: string, promp
227
267
  state.latestCommandContext = nextContext;
228
268
  const currentFile = nextContext.sessionManager.getSessionFile();
229
269
  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;
270
+ if (directory) {
271
+ // 全新会话的文件还没落盘(Pi 等首条 assistant 回复才写):本轮先在
272
+ // 继承的目录下处理,回复落盘后下一轮消息到来时锚定自动生效。
273
+ await state.controller
274
+ .notifyChat(chatId, `ℹ️ 本群已锚定到目录:${directory},将从下一条消息开始在该目录下工作。`)
275
+ .catch(() => undefined);
245
276
  }
246
277
  await nextContext.sendUserMessage(prompt);
247
278
  },
248
279
  });
249
280
  if (result.cancelled) throw new Error("创建飞书群专属 Pi 会话已取消。");
281
+ } catch (error) {
282
+ rethrowStaleAsHint(state, error);
250
283
  } finally {
251
284
  state.switchingSession = false;
252
285
  }
@@ -286,12 +319,18 @@ export async function anchorSessionHeaderCwd(sessionFile: string, directory: str
286
319
  return true;
287
320
  }
288
321
 
289
- 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 {
290
329
  const effective = safeCwd(context);
291
330
  if (effective && resolvePath(effective) !== resolvePath(directory)) {
292
- throw new Error(
293
- `群会话未能切换到绑定目录(当前:${effective})。请重启本地 Pi 后重试,或在本地执行 /feishu status 刷新会话控制。`,
294
- );
331
+ void state.controller
332
+ .notifyChat(chatId, `⚠️ 群会话未能切换到绑定目录(当前:${effective},期望:${directory})。`)
333
+ .catch(() => undefined);
295
334
  }
296
335
  }
297
336