@hwj123weijian/pi-feishu 0.11.2 → 0.12.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/README.md +10 -2
- package/package.json +1 -1
- package/src/controller.ts +79 -1
- package/src/extension.ts +41 -149
- package/src/group-agent.ts +172 -0
- package/src/scopes.ts +6 -3
package/README.md
CHANGED
|
@@ -155,13 +155,21 @@ pi -e D:\ai_study\pi-feishu
|
|
|
155
155
|
### 群聊使用
|
|
156
156
|
|
|
157
157
|
- **绑定已有群**:把机器人加进现有项目群后,Owner 在群里发送 `/bind` 即可绑定;之后该群获得独立 Pi session,与其他群和主对话互不串上下文。机器人被拉进新群时也会私聊 Owner 提醒绑定。
|
|
158
|
-
-
|
|
158
|
+
- **独立群引擎**:群消息由独立的 `pi -p` 子进程处理(不再复用你本地 TUI 的 Pi 会话)——每个群有独立的会话历史(`~/.pi/agent/feishu/group-sessions/<chatId>.jsonl`),互不串上下文,不同群可以同时对话,也不会打断或依赖你本地终端里正在跑的任务。
|
|
159
|
+
- **绑定项目目录**(推荐):发送 `/bind /path/to/project` 把群锚定到项目目录——群子进程直接在该目录下启动,bot 的读写、bash 都在这个项目里执行;Owner 显式绑定的目录会自动加入 Pi 的项目信任。私聊里说“帮我拉个群 /path/to/project”也可以在建群时一并锚定。
|
|
159
160
|
- **触发方式**:默认在群里 `@机器人 你的问题`。飞书只向 bot 推送 @bot 的群消息;普通消息直接触发需要敏感权限 `im:message.group_msg`。
|
|
160
161
|
- **授权成员**:默认只有 Owner 能使用。Owner 私聊发送 `/allow @张三` 后,张三即可在私聊和已绑定群里与机器人协作;`/deny @张三` 撤销。
|
|
161
162
|
- **分寸感**:未绑定的群里被 @ 时回复绑定引导;非授权成员被 @ 时回复一次“未授权”;其余群内消息完全静默,不会刷屏。
|
|
162
163
|
- **说话人上下文**:群消息发给 Pi 前会加上 `[飞书群聊] 说话人:` 前缀,@ 其他人会被改写成 `@名字`,模型知道在和谁对话。
|
|
164
|
+
- **群内 /new**:删除该群的会话文件,下一条群消息以全新历史开始。
|
|
163
165
|
|
|
164
|
-
|
|
166
|
+
已知限制:私聊仍驱动本地 TUI 所在的 Pi 进程(与本地终端共享引擎,排队提示如实显示位数;本地繁忙时私聊消息会等待,不会打断你)。同一群里任务运行中继续发消息会排队(群子进程按会话文件串行,保证对话顺序)。远程命令 `/model`、`/thinking`、`/compact` 作用于本地 Pi 进程,对群子进程不生效;群内 `/new` 用于重置群会话。
|
|
167
|
+
|
|
168
|
+
### 与本地使用共存
|
|
169
|
+
|
|
170
|
+
群聊走独立子进程后与本地使用完全解耦:你在终端 TUI 里跑任何任务都不会被飞书消息打断,也不需要先跑完才能处理群消息。私聊仍在本地 Pi 进程内驱动——本地繁忙时私聊消息会等待(收到提示“本地 Pi 正在执行任务”),超过 15 分钟则取消处理并通知你重发;若在本地 Pi 手动切换过会话,私聊路由的会话控制会过期一次:按提示在本地执行 `/feishu status` 刷新即可恢复。
|
|
171
|
+
|
|
172
|
+
群子进程从 PATH 中查找 `pi` 可执行文件;如不在 PATH 中,可设置 `PI_BIN` 环境变量指向 pi 的完整路径。
|
|
165
173
|
|
|
166
174
|
未知命令会返回提示,不会发给 Pi。`/model` 的候选来自本地 Pi 的 scoped models(未配置时为全部已授权模型);`/model` 无参数时展示当前模型。注意:任何以 `/` 开头的消息都会先被当作命令解析,想发给 Pi 的提问请勿以 `/` 开头。
|
|
167
175
|
|
package/package.json
CHANGED
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;
|
|
@@ -525,7 +556,11 @@ export class FeishuController {
|
|
|
525
556
|
}
|
|
526
557
|
if (!isDirectory) {
|
|
527
558
|
await gateway
|
|
528
|
-
.sendText(
|
|
559
|
+
.sendText(
|
|
560
|
+
message.chatId,
|
|
561
|
+
`❌ 目录不存在或不是文件夹:${resolved}\n用法:/bind /path/to/project`,
|
|
562
|
+
message.messageId,
|
|
563
|
+
)
|
|
529
564
|
.catch(() => undefined);
|
|
530
565
|
return;
|
|
531
566
|
}
|
|
@@ -835,8 +870,51 @@ export class FeishuController {
|
|
|
835
870
|
this.credentials = next;
|
|
836
871
|
}
|
|
837
872
|
|
|
873
|
+
/**
|
|
874
|
+
* 等待本地 Pi 空闲。超过 localBusyTimeoutMs 仍繁忙则放弃(返回 false),
|
|
875
|
+
* 等待超过 3 秒时先给聊天发一条提示,让用户知道消息没有丢。
|
|
876
|
+
*/
|
|
877
|
+
private async waitForLocalIdle(gateway: FeishuGateway, message: FeishuIncomingMessage): Promise<boolean> {
|
|
878
|
+
const runtime = this.runtime;
|
|
879
|
+
if (!runtime) return true;
|
|
880
|
+
const start = Date.now();
|
|
881
|
+
let notified = false;
|
|
882
|
+
for (;;) {
|
|
883
|
+
if (this.stopped || this.gateway !== gateway) return false;
|
|
884
|
+
let idle: boolean;
|
|
885
|
+
try {
|
|
886
|
+
idle = runtime.isIdle();
|
|
887
|
+
} catch {
|
|
888
|
+
idle = true; // 忙闲未知(ctx 失效等)时不得阻塞消息处理。
|
|
889
|
+
}
|
|
890
|
+
if (idle) return true;
|
|
891
|
+
const waitedMs = Date.now() - start;
|
|
892
|
+
if (waitedMs > this.localBusyTimeoutMs) return false;
|
|
893
|
+
if (!notified && waitedMs > this.busyNotifyAfterMs) {
|
|
894
|
+
notified = true;
|
|
895
|
+
await gateway
|
|
896
|
+
.sendText(
|
|
897
|
+
message.chatId,
|
|
898
|
+
"⏳ 本地 Pi 正在执行任务,本条消息将在其完成后处理(不会打断本地任务)。",
|
|
899
|
+
message.messageId,
|
|
900
|
+
)
|
|
901
|
+
.catch(() => undefined);
|
|
902
|
+
}
|
|
903
|
+
await sleep(this.idlePollMs);
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
|
|
838
907
|
private async processWithAgent(gateway: FeishuGateway, message: FeishuIncomingMessage, text: string): Promise<void> {
|
|
839
908
|
if (this.gateway !== gateway) return;
|
|
909
|
+
// p2p 私聊在本地 Pi 进程内驱动:本地 TUI 正在跑的任务会被会话切换 abort 掉,
|
|
910
|
+
// 因此先等本地空闲,绝不打断用户手头的工作。
|
|
911
|
+
// 群消息由独立 pi 子进程承载,与本地会话互不影响,无需等待。
|
|
912
|
+
if (message.chatType !== "group" && !(await this.waitForLocalIdle(gateway, message))) {
|
|
913
|
+
await gateway
|
|
914
|
+
.sendText(message.chatId, "⏳ 本地 Pi 长时间繁忙,本条消息已取消处理,请稍后重发。", message.messageId)
|
|
915
|
+
.catch(() => undefined);
|
|
916
|
+
return;
|
|
917
|
+
}
|
|
840
918
|
const reply = await gateway.beginReply(message.chatId, message.messageId).catch(() => undefined);
|
|
841
919
|
if (reply) this.activeReplies.add(reply);
|
|
842
920
|
let latestText = "";
|
package/src/extension.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { resolve as resolvePath } from "node:path";
|
|
1
|
+
import { rm } from "node:fs/promises";
|
|
3
2
|
import type {
|
|
4
3
|
ExtensionAPI,
|
|
5
4
|
ExtensionCommandContext,
|
|
@@ -7,19 +6,18 @@ import type {
|
|
|
7
6
|
ProjectTrustHandler,
|
|
8
7
|
} from "@earendil-works/pi-coding-agent";
|
|
9
8
|
import { Type } from "typebox";
|
|
10
|
-
import type {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
: never
|
|
19
|
-
>;
|
|
9
|
+
import type {
|
|
10
|
+
AgentBridge,
|
|
11
|
+
AgentProgressObserver,
|
|
12
|
+
FeishuStatus,
|
|
13
|
+
PiModelInfo,
|
|
14
|
+
PiRuntime,
|
|
15
|
+
PiRuntimeSnapshot,
|
|
16
|
+
} from "./contracts.js";
|
|
20
17
|
import { FeishuController } from "./controller.js";
|
|
21
18
|
import { CredentialError, FileCredentialStore } from "./credentials.js";
|
|
22
19
|
import { SdkFeishuGateway, validateSdkCredentials } from "./gateway.js";
|
|
20
|
+
import { defaultGroupSessionFile, GroupAgentRunner } from "./group-agent.js";
|
|
23
21
|
import { PiAgentBridge } from "./pi-agent-bridge.js";
|
|
24
22
|
import { THINKING_LEVELS } from "./remote-commands.js";
|
|
25
23
|
import {
|
|
@@ -58,6 +56,8 @@ interface SharedFeishuState {
|
|
|
58
56
|
* 不能据此取消正在等待的飞书轮次(否则群消息必然失败)。
|
|
59
57
|
*/
|
|
60
58
|
switchingSession: boolean;
|
|
59
|
+
/** 群消息子进程运行器(每群独立 pi -p 进程,不碰本地会话)。 */
|
|
60
|
+
groupRunner: GroupAgentRunner;
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
const SHARED_STATE_KEY = "__piFeishuSharedState__";
|
|
@@ -80,15 +80,16 @@ function getSharedState(): SharedFeishuState {
|
|
|
80
80
|
mainSessionFile: undefined,
|
|
81
81
|
currentSessionFile: undefined,
|
|
82
82
|
switchingSession: false,
|
|
83
|
+
groupRunner: new GroupAgentRunner(),
|
|
83
84
|
controller: undefined as unknown as FeishuController,
|
|
84
85
|
};
|
|
85
86
|
const proxyAgent: AgentBridge = {
|
|
86
87
|
run: (text, observer, options) => {
|
|
88
|
+
if (options?.chatId) {
|
|
89
|
+
// 群消息走独立 pi 子进程:不切本地会话、不碰 TUI、目录天然锚定。
|
|
90
|
+
return runGroupAgent(state, options.chatId, text, observer);
|
|
91
|
+
}
|
|
87
92
|
const bridge = new PiAgentBridge(async (prompt) => {
|
|
88
|
-
if (options?.chatId) {
|
|
89
|
-
await sendToChatSession(state, options.chatId, prompt);
|
|
90
|
-
return;
|
|
91
|
-
}
|
|
92
93
|
await sendToMainSession(state, prompt);
|
|
93
94
|
});
|
|
94
95
|
state.activeBridge = bridge;
|
|
@@ -96,9 +97,14 @@ function getSharedState(): SharedFeishuState {
|
|
|
96
97
|
if (state.activeBridge === bridge) state.activeBridge = undefined;
|
|
97
98
|
});
|
|
98
99
|
},
|
|
99
|
-
cancel: (reason) =>
|
|
100
|
+
cancel: (reason) => {
|
|
101
|
+
state.activeBridge?.cancel(reason);
|
|
102
|
+
// 群消息由独立子进程承载:一并终止它们。
|
|
103
|
+
state.groupRunner.stop();
|
|
104
|
+
},
|
|
100
105
|
steer: (text) => {
|
|
101
106
|
// 仅当确有飞书轮次在跑时并入:发到当前活跃会话(轮次就在那里),绝不触发会话切换。
|
|
107
|
+
// 群消息走独立子进程,不经过这里。
|
|
102
108
|
if (!state.activeBridge || !state.sendCurrentMessage) return false;
|
|
103
109
|
try {
|
|
104
110
|
state.sendCurrentMessage(text, { deliverAs: "steer" });
|
|
@@ -115,22 +121,13 @@ function getSharedState(): SharedFeishuState {
|
|
|
115
121
|
compact: () => state.current?.runtime.compact(),
|
|
116
122
|
newSession: () => state.current?.runtime.newSession() ?? Promise.resolve(false),
|
|
117
123
|
newChatSession: async (chatId) => {
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
state.switchingSession = true;
|
|
124
|
+
// 群会话由独立子进程承载:/new 等价于删除该群的 session 文件,
|
|
125
|
+
// 下一条群消息的子进程会以全新历史启动。
|
|
121
126
|
try {
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
if (sessionFile) await state.controller.setChatSessionFile(chatId, sessionFile);
|
|
127
|
-
},
|
|
128
|
-
});
|
|
129
|
-
return !result.cancelled;
|
|
130
|
-
} catch (error) {
|
|
131
|
-
rethrowStaleAsHint(state, error);
|
|
132
|
-
} finally {
|
|
133
|
-
state.switchingSession = false;
|
|
127
|
+
await rm(defaultGroupSessionFile(chatId), { force: true });
|
|
128
|
+
return true;
|
|
129
|
+
} catch {
|
|
130
|
+
return false;
|
|
134
131
|
}
|
|
135
132
|
},
|
|
136
133
|
setThinkingLevel: (level) => state.current?.runtime.setThinkingLevel(level) ?? Promise.resolve(false),
|
|
@@ -225,131 +222,26 @@ async function sendToMainSession(state: SharedFeishuState, prompt: string): Prom
|
|
|
225
222
|
}
|
|
226
223
|
}
|
|
227
224
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
const context = state.latestCommandContext;
|
|
236
|
-
if (!context) throw new Error(SESSION_CONTROL_HINT);
|
|
237
|
-
|
|
225
|
+
/** 群消息:spawn 独立 pi 子进程(cwd=绑定目录,session 文件按群隔离)。 */
|
|
226
|
+
async function runGroupAgent(
|
|
227
|
+
state: SharedFeishuState,
|
|
228
|
+
chatId: string,
|
|
229
|
+
prompt: string,
|
|
230
|
+
observer: AgentProgressObserver | undefined,
|
|
231
|
+
): Promise<string> {
|
|
238
232
|
const directory = state.controller.getChatDirectory(chatId);
|
|
239
|
-
|
|
233
|
+
const sessionFile = defaultGroupSessionFile(chatId);
|
|
240
234
|
try {
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
// SessionManager.open 切换时从 header 读取 cwd,runtime 随之在新目录重建。
|
|
244
|
-
if (directory && !(await anchorSessionHeaderCwd(sessionFile, directory))) {
|
|
245
|
-
throw new Error(`无法把群会话锚定到 ${directory}(会话文件不可写或格式未知)。`);
|
|
246
|
-
}
|
|
247
|
-
const result = await context.switchSession(sessionFile, {
|
|
248
|
-
withSession: (nextContext) => {
|
|
249
|
-
if (directory) assertAnchoredCwd(nextContext, directory);
|
|
250
|
-
return sendAndTrackGroupSession(state, chatId, sessionFile, nextContext, prompt);
|
|
251
|
-
},
|
|
252
|
-
});
|
|
253
|
-
if (result.cancelled) throw new Error("切换到飞书群绑定的 Pi 会话已取消。");
|
|
254
|
-
return;
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
const result = await context.newSession({
|
|
258
|
-
withSession: async (nextContext) => {
|
|
259
|
-
state.latestCommandContext = nextContext;
|
|
260
|
-
const currentFile = nextContext.sessionManager.getSessionFile();
|
|
261
|
-
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;
|
|
277
|
-
}
|
|
278
|
-
await nextContext.sendUserMessage(prompt);
|
|
279
|
-
},
|
|
280
|
-
});
|
|
281
|
-
if (result.cancelled) throw new Error("创建飞书群专属 Pi 会话已取消。");
|
|
235
|
+
const result = await state.groupRunner.run(sessionFile, directory, prompt, observer);
|
|
236
|
+
return result.text || "Pi 已完成处理,但没有返回文本内容。";
|
|
282
237
|
} catch (error) {
|
|
283
|
-
|
|
284
|
-
} finally {
|
|
285
|
-
state.switchingSession = false;
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
/**
|
|
290
|
-
* 把会话文件头(JSONL 首行的 session header)里的 cwd 改写为绑定目录。
|
|
291
|
-
* 群会话空闲时文件不被任何 runtime 持有,重写首行是安全的;下次
|
|
292
|
-
* SessionManager.open 会以新 cwd 重建 runtime(工具、bash 都在新目录执行)。
|
|
293
|
-
* 返回 false 表示文件不可读/格式未知,调用方给出可操作错误。
|
|
294
|
-
*/
|
|
295
|
-
export async function anchorSessionHeaderCwd(sessionFile: string, directory: string): Promise<boolean> {
|
|
296
|
-
let raw: string;
|
|
297
|
-
try {
|
|
298
|
-
raw = await readFile(sessionFile, "utf8");
|
|
299
|
-
} catch {
|
|
300
|
-
return false;
|
|
301
|
-
}
|
|
302
|
-
const newlineIndex = raw.indexOf("\n");
|
|
303
|
-
const firstLine = newlineIndex === -1 ? raw : raw.slice(0, newlineIndex);
|
|
304
|
-
let header: Record<string, unknown>;
|
|
305
|
-
try {
|
|
306
|
-
header = JSON.parse(firstLine) as Record<string, unknown>;
|
|
307
|
-
} catch {
|
|
308
|
-
return false;
|
|
309
|
-
}
|
|
310
|
-
if (header.type !== "session" || typeof header.cwd !== "string") return false;
|
|
311
|
-
if (resolvePath(header.cwd) === resolvePath(directory)) return true;
|
|
312
|
-
header.cwd = directory;
|
|
313
|
-
const updatedFirstLine = JSON.stringify(header);
|
|
314
|
-
const updated = newlineIndex === -1 ? updatedFirstLine : updatedFirstLine + raw.slice(newlineIndex);
|
|
315
|
-
try {
|
|
316
|
-
await writeFile(sessionFile, updated, "utf8");
|
|
317
|
-
} catch {
|
|
318
|
-
return false;
|
|
319
|
-
}
|
|
320
|
-
return true;
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
function assertAnchoredCwd(context: ExtensionContext, directory: string): void {
|
|
324
|
-
const effective = safeCwd(context);
|
|
325
|
-
if (effective && resolvePath(effective) !== resolvePath(directory)) {
|
|
238
|
+
const reason = state.controller.sanitizeError(error);
|
|
326
239
|
throw new Error(
|
|
327
|
-
|
|
240
|
+
`${reason}(群聊由独立 pi 进程处理${directory ? `,工作目录:${directory}` : ""};若 pi 不在 PATH 中,可设置 PI_BIN 环境变量)`,
|
|
328
241
|
);
|
|
329
242
|
}
|
|
330
243
|
}
|
|
331
244
|
|
|
332
|
-
function safeCwd(context: ExtensionContext): string | undefined {
|
|
333
|
-
try {
|
|
334
|
-
return context.cwd;
|
|
335
|
-
} catch {
|
|
336
|
-
return undefined;
|
|
337
|
-
}
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
async function sendAndTrackGroupSession(
|
|
341
|
-
state: SharedFeishuState,
|
|
342
|
-
chatId: string,
|
|
343
|
-
sessionFile: string,
|
|
344
|
-
nextContext: SwitchCallback,
|
|
345
|
-
prompt: string,
|
|
346
|
-
): Promise<void> {
|
|
347
|
-
state.latestCommandContext = nextContext;
|
|
348
|
-
const currentFile = nextContext.sessionManager.getSessionFile();
|
|
349
|
-
if (currentFile && currentFile !== sessionFile) await state.controller.setChatSessionFile(chatId, currentFile);
|
|
350
|
-
await nextContext.sendUserMessage(prompt);
|
|
351
|
-
}
|
|
352
|
-
|
|
353
245
|
export interface ParsedFeishuCommand {
|
|
354
246
|
name: FeishuCommandName;
|
|
355
247
|
args: string;
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { mkdir } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import type { AgentProgressObserver } from "./contracts.js";
|
|
6
|
+
|
|
7
|
+
export interface GroupAgentRunResult {
|
|
8
|
+
text: string;
|
|
9
|
+
exitCode: number | null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface GroupAgentRunOptions {
|
|
13
|
+
/** 硬超时:超时后 SIGTERM 子进程。 */
|
|
14
|
+
timeoutMs?: number;
|
|
15
|
+
/** pi 可执行文件;默认从 PATH 找 pi,可用 PI_BIN 覆盖。 */
|
|
16
|
+
bin?: string;
|
|
17
|
+
/** 测试注入:替换 spawn。 */
|
|
18
|
+
spawnImpl?: typeof spawn;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const DEFAULT_TIMEOUT_MS = 15 * 60 * 1000;
|
|
22
|
+
|
|
23
|
+
interface PrintEvent {
|
|
24
|
+
type?: string;
|
|
25
|
+
message?: { role?: string; content?: unknown };
|
|
26
|
+
toolName?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 群聊代理运行器:为每条群消息 spawn 一个独立的 `pi -p --mode json` 子进程。
|
|
31
|
+
*
|
|
32
|
+
* 与 easycodeclient「每群独立引擎」对齐的架构:
|
|
33
|
+
* - cwd 直接设为群的绑定目录 → 目录锚定天然成立;
|
|
34
|
+
* - 每个群固定一个 session 文件 → 历史隔离且跨重启续接;
|
|
35
|
+
* - 完全不触碰用户本地 TUI 的会话(不会被 abort、没有 stale ctx);
|
|
36
|
+
* - 不同群的子进程彼此独立 → 群间真实并行。
|
|
37
|
+
*/
|
|
38
|
+
export class GroupAgentRunner {
|
|
39
|
+
private readonly timeoutMs: number;
|
|
40
|
+
private readonly bin: string;
|
|
41
|
+
private readonly spawnImpl: typeof spawn;
|
|
42
|
+
private readonly children = new Set<ReturnType<typeof spawn>>();
|
|
43
|
+
|
|
44
|
+
constructor(options: GroupAgentRunOptions = {}) {
|
|
45
|
+
this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
46
|
+
this.bin = options.bin ?? process.env.PI_BIN ?? "pi";
|
|
47
|
+
this.spawnImpl = options.spawnImpl ?? spawn;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** 当前在跑的子进程数(用于测试与状态展示)。 */
|
|
51
|
+
get runningCount(): number {
|
|
52
|
+
return this.children.size;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** 中止所有在跑的群子进程(远程 /stop);返回数量。 */
|
|
56
|
+
stop(): number {
|
|
57
|
+
let killed = 0;
|
|
58
|
+
for (const child of this.children) {
|
|
59
|
+
child.kill("SIGTERM");
|
|
60
|
+
killed += 1;
|
|
61
|
+
}
|
|
62
|
+
return killed;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async run(
|
|
66
|
+
sessionFile: string,
|
|
67
|
+
directory: string | undefined,
|
|
68
|
+
prompt: string,
|
|
69
|
+
observer?: AgentProgressObserver,
|
|
70
|
+
): Promise<GroupAgentRunResult> {
|
|
71
|
+
await mkdir(dirname(sessionFile), { recursive: true, mode: 0o700 });
|
|
72
|
+
const child = this.spawnImpl(this.bin, ["-p", "--mode", "json", "--session", sessionFile, prompt], {
|
|
73
|
+
cwd: directory ?? process.cwd(),
|
|
74
|
+
env: process.env,
|
|
75
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
76
|
+
});
|
|
77
|
+
this.children.add(child);
|
|
78
|
+
|
|
79
|
+
return new Promise<GroupAgentRunResult>((resolve, reject) => {
|
|
80
|
+
let stdoutBuffer = "";
|
|
81
|
+
let stderrBuffer = "";
|
|
82
|
+
let latestAssistantText = "";
|
|
83
|
+
let settled = false;
|
|
84
|
+
const timer = setTimeout(() => {
|
|
85
|
+
child.kill("SIGTERM");
|
|
86
|
+
}, this.timeoutMs);
|
|
87
|
+
|
|
88
|
+
const finish = (error?: Error) => {
|
|
89
|
+
if (settled) return;
|
|
90
|
+
settled = true;
|
|
91
|
+
clearTimeout(timer);
|
|
92
|
+
this.children.delete(child);
|
|
93
|
+
if (error) {
|
|
94
|
+
reject(error);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
resolve({ text: latestAssistantText, exitCode: child.exitCode });
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
child.stdout?.on("data", (chunk: Buffer) => {
|
|
101
|
+
stdoutBuffer += chunk.toString("utf8");
|
|
102
|
+
let newlineIndex = stdoutBuffer.indexOf("\n");
|
|
103
|
+
while (newlineIndex !== -1) {
|
|
104
|
+
const line = stdoutBuffer.slice(0, newlineIndex).trim();
|
|
105
|
+
stdoutBuffer = stdoutBuffer.slice(newlineIndex + 1);
|
|
106
|
+
this.handleJsonLine(line, observer, (text) => {
|
|
107
|
+
latestAssistantText = text;
|
|
108
|
+
});
|
|
109
|
+
newlineIndex = stdoutBuffer.indexOf("\n");
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
child.stderr?.on("data", (chunk: Buffer) => {
|
|
113
|
+
stderrBuffer += chunk.toString("utf8");
|
|
114
|
+
});
|
|
115
|
+
child.on("error", (error) => {
|
|
116
|
+
finish(new Error(`启动 pi 子进程失败(cwd: ${directory ?? process.cwd()}):${error.message}`));
|
|
117
|
+
});
|
|
118
|
+
child.on("close", (code) => {
|
|
119
|
+
if (code === 0) {
|
|
120
|
+
observer?.onText?.(latestAssistantText);
|
|
121
|
+
finish();
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
const stderr = stderrBuffer.trim();
|
|
125
|
+
finish(new Error(stderr ? `pi 子进程退出码 ${code}:${stderr}` : `pi 子进程退出码 ${code}`));
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
private handleJsonLine(
|
|
131
|
+
line: string,
|
|
132
|
+
observer: AgentProgressObserver | undefined,
|
|
133
|
+
setLatest: (text: string) => void,
|
|
134
|
+
): void {
|
|
135
|
+
if (!line) return;
|
|
136
|
+
let event: PrintEvent;
|
|
137
|
+
try {
|
|
138
|
+
event = JSON.parse(line) as PrintEvent;
|
|
139
|
+
} catch {
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
if (event.type === "tool_execution_start") {
|
|
143
|
+
const toolName = event.toolName ?? "tool";
|
|
144
|
+
observer?.onActivity?.({ kind: "tool", toolName });
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (event.type !== "message_update" || event.message?.role !== "assistant") return;
|
|
148
|
+
const text = extractText(event.message.content);
|
|
149
|
+
if (text) {
|
|
150
|
+
setLatest(text);
|
|
151
|
+
observer?.onText?.(text);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function extractText(content: unknown): string {
|
|
157
|
+
if (!Array.isArray(content)) return "";
|
|
158
|
+
const parts: string[] = [];
|
|
159
|
+
for (const item of content) {
|
|
160
|
+
if (item && typeof item === "object" && (item as { type?: string }).type === "text") {
|
|
161
|
+
const text = (item as { text?: unknown }).text;
|
|
162
|
+
if (typeof text === "string") parts.push(text);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return parts.join("").trimEnd();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** 群专属 session 文件:~/.pi/agent/feishu/group-sessions/<chatId>.jsonl,由 pi CLI 自管。 */
|
|
169
|
+
export function defaultGroupSessionFile(chatId: string): string {
|
|
170
|
+
const safe = chatId.replace(/[^A-Za-z0-9_-]/g, "_");
|
|
171
|
+
return join(homedir(), ".pi", "agent", "feishu", "group-sessions", `${safe}.jsonl`);
|
|
172
|
+
}
|
package/src/scopes.ts
CHANGED
|
@@ -196,9 +196,12 @@ export function buildScopeHealthSection(appId: string, grantedScopes?: readonly
|
|
|
196
196
|
const missing = missingScopes(grantedScopes, REQUIRED_APP_SCOPES);
|
|
197
197
|
const hasGroupMsg = hasScope(grantedScopes, SENSITIVE_GROUP_MSG_SCOPE);
|
|
198
198
|
if (missing.length === 0 && hasGroupMsg) {
|
|
199
|
-
return [
|
|
200
|
-
"
|
|
201
|
-
|
|
199
|
+
return [
|
|
200
|
+
"✅ 应用权限配置完整,所有功能均可正常使用。",
|
|
201
|
+
"",
|
|
202
|
+
"本 Bot 依赖以下权限(均已开通):",
|
|
203
|
+
...renderScopeInventory(),
|
|
204
|
+
].join("\n");
|
|
202
205
|
}
|
|
203
206
|
const lines = ["⚠️ 以下应用权限尚未开通,对应功能会受限:"];
|
|
204
207
|
if (missing.length > 0) {
|