@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 +2 -1
- package/package.json +1 -1
- package/src/contracts.ts +7 -0
- package/src/controller.ts +108 -7
- package/src/credentials.ts +4 -1
- package/src/extension.ts +135 -13
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
|
|
164
|
+
已知限制:Pi 是单会话进程,群与群、群与私聊之间共享一个执行引擎,**跨聊天**的任务仍全局串行(排队提示会如实显示位数)。同一聊天内任务运行中继续发消息,会直接并入当前对话(steer),无需排队。远程命令作用于最近活跃的会话,`/model`、`/thinking`、`/compact` 建议在对应聊天的上一条消息之后紧接着发送。
|
|
164
165
|
|
|
165
166
|
未知命令会返回提示,不会发给 Pi。`/model` 的候选来自本地 Pi 的 scoped models(未配置时为全部已授权模型);`/model` 无参数时展示当前模型。注意:任何以 `/` 开头的消息都会先被当作命令解析,想发给 Pi 的提问请勿以 `/` 开头。
|
|
166
167
|
|
package/package.json
CHANGED
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
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
package/src/credentials.ts
CHANGED
|
@@ -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
|
|
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:
|
|
189
|
-
|
|
190
|
-
|
|
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.
|
|
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.
|
|
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: `群聊「${
|
|
645
|
+
text: `群聊「${name}」已创建,群聊 ID:${chatId}${anchored}。已邀请当前绑定的飞书用户,并发送群欢迎消息。`,
|
|
524
646
|
},
|
|
525
647
|
],
|
|
526
|
-
details: { chatId, name
|
|
648
|
+
details: { chatId, name },
|
|
527
649
|
};
|
|
528
650
|
} catch (error) {
|
|
529
651
|
return {
|