@hwj123weijian/pi-feishu 0.11.3 → 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 +7 -3
- package/package.json +1 -1
- package/src/controller.ts +9 -4
- package/src/extension.ts +43 -156
- package/src/group-agent.ts +172 -0
- package/src/scopes.ts +6 -3
package/README.md
CHANGED
|
@@ -155,17 +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` 用于重置群会话。
|
|
165
167
|
|
|
166
168
|
### 与本地使用共存
|
|
167
169
|
|
|
168
|
-
|
|
170
|
+
群聊走独立子进程后与本地使用完全解耦:你在终端 TUI 里跑任何任务都不会被飞书消息打断,也不需要先跑完才能处理群消息。私聊仍在本地 Pi 进程内驱动——本地繁忙时私聊消息会等待(收到提示“本地 Pi 正在执行任务”),超过 15 分钟则取消处理并通知你重发;若在本地 Pi 手动切换过会话,私聊路由的会话控制会过期一次:按提示在本地执行 `/feishu status` 刷新即可恢复。
|
|
171
|
+
|
|
172
|
+
群子进程从 PATH 中查找 `pi` 可执行文件;如不在 PATH 中,可设置 `PI_BIN` 环境变量指向 pi 的完整路径。
|
|
169
173
|
|
|
170
174
|
未知命令会返回提示,不会发给 Pi。`/model` 的候选来自本地 Pi 的 scoped models(未配置时为全部已授权模型);`/model` 无参数时展示当前模型。注意:任何以 `/` 开头的消息都会先被当作命令解析,想发给 Pi 的提问请勿以 `/` 开头。
|
|
171
175
|
|
package/package.json
CHANGED
package/src/controller.ts
CHANGED
|
@@ -556,7 +556,11 @@ export class FeishuController {
|
|
|
556
556
|
}
|
|
557
557
|
if (!isDirectory) {
|
|
558
558
|
await gateway
|
|
559
|
-
.sendText(
|
|
559
|
+
.sendText(
|
|
560
|
+
message.chatId,
|
|
561
|
+
`❌ 目录不存在或不是文件夹:${resolved}\n用法:/bind /path/to/project`,
|
|
562
|
+
message.messageId,
|
|
563
|
+
)
|
|
560
564
|
.catch(() => undefined);
|
|
561
565
|
return;
|
|
562
566
|
}
|
|
@@ -902,9 +906,10 @@ export class FeishuController {
|
|
|
902
906
|
|
|
903
907
|
private async processWithAgent(gateway: FeishuGateway, message: FeishuIncomingMessage, text: string): Promise<void> {
|
|
904
908
|
if (this.gateway !== gateway) return;
|
|
905
|
-
// Pi
|
|
906
|
-
//
|
|
907
|
-
|
|
909
|
+
// p2p 私聊在本地 Pi 进程内驱动:本地 TUI 正在跑的任务会被会话切换 abort 掉,
|
|
910
|
+
// 因此先等本地空闲,绝不打断用户手头的工作。
|
|
911
|
+
// 群消息由独立 pi 子进程承载,与本地会话互不影响,无需等待。
|
|
912
|
+
if (message.chatType !== "group" && !(await this.waitForLocalIdle(gateway, message))) {
|
|
908
913
|
await gateway
|
|
909
914
|
.sendText(message.chatId, "⏳ 本地 Pi 长时间繁忙,本条消息已取消处理,请稍后重发。", message.messageId)
|
|
910
915
|
.catch(() => undefined);
|
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,136 +222,26 @@ async function sendToMainSession(state: SharedFeishuState, prompt: string): Prom
|
|
|
225
222
|
}
|
|
226
223
|
}
|
|
227
224
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
// Pi 侧切换后当前会话可能恰好就是目标群会话:此时无需命令上下文,直接投递。
|
|
231
|
-
if (sessionFile && sessionFile === state.currentSessionFile && state.sendCurrentMessage) {
|
|
232
|
-
state.sendCurrentMessage(prompt);
|
|
233
|
-
return;
|
|
234
|
-
}
|
|
235
|
-
const context = state.latestCommandContext;
|
|
236
|
-
if (!context) throw new Error(SESSION_CONTROL_HINT);
|
|
237
|
-
|
|
238
|
-
const directory = state.controller.getChatDirectory(chatId);
|
|
239
|
-
state.switchingSession = true;
|
|
240
|
-
try {
|
|
241
|
-
if (sessionFile) {
|
|
242
|
-
// Pi 的 TUI switchSession 不透传 cwdOverride,改为把 cwd 写进会话头:
|
|
243
|
-
// SessionManager.open 切换时从 header 读取 cwd,runtime 随之在新目录重建。
|
|
244
|
-
// 会话文件可能尚未落盘(Pi 要等首条 assistant 回复才写文件)——此时跳过
|
|
245
|
-
// 锚定正常处理,本轮回复落盘后下一轮自动生效,绝不因锚定失败终止消息。
|
|
246
|
-
let anchored = true;
|
|
247
|
-
if (directory) {
|
|
248
|
-
anchored = await anchorSessionHeaderCwd(sessionFile, directory);
|
|
249
|
-
}
|
|
250
|
-
const result = await context.switchSession(sessionFile, {
|
|
251
|
-
withSession: (nextContext) => {
|
|
252
|
-
if (directory && anchored) checkAnchoredCwd(state, chatId, nextContext, directory);
|
|
253
|
-
return sendAndTrackGroupSession(state, chatId, sessionFile, nextContext, prompt);
|
|
254
|
-
},
|
|
255
|
-
});
|
|
256
|
-
if (result.cancelled) throw new Error("切换到飞书群绑定的 Pi 会话已取消。");
|
|
257
|
-
if (directory && !anchored) {
|
|
258
|
-
await state.controller
|
|
259
|
-
.notifyChat(chatId, "ℹ️ 本群的目录锚定将在下一条消息生效(当前会话文件尚未写入磁盘)。")
|
|
260
|
-
.catch(() => undefined);
|
|
261
|
-
}
|
|
262
|
-
return;
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
const result = await context.newSession({
|
|
266
|
-
withSession: async (nextContext) => {
|
|
267
|
-
state.latestCommandContext = nextContext;
|
|
268
|
-
const currentFile = nextContext.sessionManager.getSessionFile();
|
|
269
|
-
if (currentFile) await state.controller.setChatSessionFile(chatId, currentFile);
|
|
270
|
-
if (directory) {
|
|
271
|
-
// 全新会话的文件还没落盘(Pi 等首条 assistant 回复才写):本轮先在
|
|
272
|
-
// 继承的目录下处理,回复落盘后下一轮消息到来时锚定自动生效。
|
|
273
|
-
await state.controller
|
|
274
|
-
.notifyChat(chatId, `ℹ️ 本群已锚定到目录:${directory},将从下一条消息开始在该目录下工作。`)
|
|
275
|
-
.catch(() => undefined);
|
|
276
|
-
}
|
|
277
|
-
await nextContext.sendUserMessage(prompt);
|
|
278
|
-
},
|
|
279
|
-
});
|
|
280
|
-
if (result.cancelled) throw new Error("创建飞书群专属 Pi 会话已取消。");
|
|
281
|
-
} catch (error) {
|
|
282
|
-
rethrowStaleAsHint(state, error);
|
|
283
|
-
} finally {
|
|
284
|
-
state.switchingSession = false;
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
/**
|
|
289
|
-
* 把会话文件头(JSONL 首行的 session header)里的 cwd 改写为绑定目录。
|
|
290
|
-
* 群会话空闲时文件不被任何 runtime 持有,重写首行是安全的;下次
|
|
291
|
-
* SessionManager.open 会以新 cwd 重建 runtime(工具、bash 都在新目录执行)。
|
|
292
|
-
* 返回 false 表示文件不可读/格式未知,调用方给出可操作错误。
|
|
293
|
-
*/
|
|
294
|
-
export async function anchorSessionHeaderCwd(sessionFile: string, directory: string): Promise<boolean> {
|
|
295
|
-
let raw: string;
|
|
296
|
-
try {
|
|
297
|
-
raw = await readFile(sessionFile, "utf8");
|
|
298
|
-
} catch {
|
|
299
|
-
return false;
|
|
300
|
-
}
|
|
301
|
-
const newlineIndex = raw.indexOf("\n");
|
|
302
|
-
const firstLine = newlineIndex === -1 ? raw : raw.slice(0, newlineIndex);
|
|
303
|
-
let header: Record<string, unknown>;
|
|
304
|
-
try {
|
|
305
|
-
header = JSON.parse(firstLine) as Record<string, unknown>;
|
|
306
|
-
} catch {
|
|
307
|
-
return false;
|
|
308
|
-
}
|
|
309
|
-
if (header.type !== "session" || typeof header.cwd !== "string") return false;
|
|
310
|
-
if (resolvePath(header.cwd) === resolvePath(directory)) return true;
|
|
311
|
-
header.cwd = directory;
|
|
312
|
-
const updatedFirstLine = JSON.stringify(header);
|
|
313
|
-
const updated = newlineIndex === -1 ? updatedFirstLine : updatedFirstLine + raw.slice(newlineIndex);
|
|
314
|
-
try {
|
|
315
|
-
await writeFile(sessionFile, updated, "utf8");
|
|
316
|
-
} catch {
|
|
317
|
-
return false;
|
|
318
|
-
}
|
|
319
|
-
return true;
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
/** 锚定后校验 runtime cwd 是否真的切了过去;不符只提示,不终止消息。 */
|
|
323
|
-
function checkAnchoredCwd(
|
|
225
|
+
/** 群消息:spawn 独立 pi 子进程(cwd=绑定目录,session 文件按群隔离)。 */
|
|
226
|
+
async function runGroupAgent(
|
|
324
227
|
state: SharedFeishuState,
|
|
325
228
|
chatId: string,
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
):
|
|
329
|
-
const
|
|
330
|
-
|
|
331
|
-
void state.controller
|
|
332
|
-
.notifyChat(chatId, `⚠️ 群会话未能切换到绑定目录(当前:${effective},期望:${directory})。`)
|
|
333
|
-
.catch(() => undefined);
|
|
334
|
-
}
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
function safeCwd(context: ExtensionContext): string | undefined {
|
|
229
|
+
prompt: string,
|
|
230
|
+
observer: AgentProgressObserver | undefined,
|
|
231
|
+
): Promise<string> {
|
|
232
|
+
const directory = state.controller.getChatDirectory(chatId);
|
|
233
|
+
const sessionFile = defaultGroupSessionFile(chatId);
|
|
338
234
|
try {
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
235
|
+
const result = await state.groupRunner.run(sessionFile, directory, prompt, observer);
|
|
236
|
+
return result.text || "Pi 已完成处理,但没有返回文本内容。";
|
|
237
|
+
} catch (error) {
|
|
238
|
+
const reason = state.controller.sanitizeError(error);
|
|
239
|
+
throw new Error(
|
|
240
|
+
`${reason}(群聊由独立 pi 进程处理${directory ? `,工作目录:${directory}` : ""};若 pi 不在 PATH 中,可设置 PI_BIN 环境变量)`,
|
|
241
|
+
);
|
|
342
242
|
}
|
|
343
243
|
}
|
|
344
244
|
|
|
345
|
-
async function sendAndTrackGroupSession(
|
|
346
|
-
state: SharedFeishuState,
|
|
347
|
-
chatId: string,
|
|
348
|
-
sessionFile: string,
|
|
349
|
-
nextContext: SwitchCallback,
|
|
350
|
-
prompt: string,
|
|
351
|
-
): Promise<void> {
|
|
352
|
-
state.latestCommandContext = nextContext;
|
|
353
|
-
const currentFile = nextContext.sessionManager.getSessionFile();
|
|
354
|
-
if (currentFile && currentFile !== sessionFile) await state.controller.setChatSessionFile(chatId, currentFile);
|
|
355
|
-
await nextContext.sendUserMessage(prompt);
|
|
356
|
-
}
|
|
357
|
-
|
|
358
245
|
export interface ParsedFeishuCommand {
|
|
359
246
|
name: FeishuCommandName;
|
|
360
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) {
|