@hwj123weijian/pi-feishu 0.11.0 → 0.11.2
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/package.json +1 -1
- package/src/extension.ts +103 -44
package/package.json
CHANGED
package/src/extension.ts
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { resolve as resolvePath } from "node:path";
|
|
1
3
|
import type {
|
|
2
4
|
ExtensionAPI,
|
|
3
5
|
ExtensionCommandContext,
|
|
4
6
|
ExtensionContext,
|
|
5
|
-
ProjectTrustEventResult,
|
|
6
7
|
ProjectTrustHandler,
|
|
7
8
|
} from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import { Type } from "typebox";
|
|
10
|
+
import type { AgentBridge, FeishuStatus, PiModelInfo, PiRuntime, PiRuntimeSnapshot } from "./contracts.js";
|
|
8
11
|
|
|
9
12
|
/** switchSession/withSession 回调里的新会话上下文;Pi 未从包根导出该类型名,从签名提取。 */
|
|
10
13
|
type SwitchCallback = NonNullable<
|
|
@@ -14,9 +17,6 @@ type SwitchCallback = NonNullable<
|
|
|
14
17
|
: never
|
|
15
18
|
: never
|
|
16
19
|
>;
|
|
17
|
-
type WithSessionCallback = (ctx: SwitchCallback) => Promise<void>;
|
|
18
|
-
import { Type } from "typebox";
|
|
19
|
-
import type { AgentBridge, FeishuStatus, PiModelInfo, PiRuntime, PiRuntimeSnapshot } from "./contracts.js";
|
|
20
20
|
import { FeishuController } from "./controller.js";
|
|
21
21
|
import { CredentialError, FileCredentialStore } from "./credentials.js";
|
|
22
22
|
import { SdkFeishuGateway, validateSdkCredentials } from "./gateway.js";
|
|
@@ -100,8 +100,13 @@ function getSharedState(): SharedFeishuState {
|
|
|
100
100
|
steer: (text) => {
|
|
101
101
|
// 仅当确有飞书轮次在跑时并入:发到当前活跃会话(轮次就在那里),绝不触发会话切换。
|
|
102
102
|
if (!state.activeBridge || !state.sendCurrentMessage) return false;
|
|
103
|
-
|
|
104
|
-
|
|
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(
|
|
210
|
+
throw new Error(SESSION_CONTROL_HINT);
|
|
186
211
|
}
|
|
187
212
|
state.switchingSession = true;
|
|
188
213
|
try {
|
|
@@ -193,23 +218,38 @@ 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(
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
)
|
|
242
|
+
// Pi 的 TUI switchSession 不透传 cwdOverride,改为把 cwd 写进会话头:
|
|
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
|
+
});
|
|
213
253
|
if (result.cancelled) throw new Error("切换到飞书群绑定的 Pi 会话已取消。");
|
|
214
254
|
return;
|
|
215
255
|
}
|
|
@@ -219,11 +259,18 @@ async function sendToChatSession(state: SharedFeishuState, chatId: string, promp
|
|
|
219
259
|
state.latestCommandContext = nextContext;
|
|
220
260
|
const currentFile = nextContext.sessionManager.getSessionFile();
|
|
221
261
|
if (currentFile) await state.controller.setChatSessionFile(chatId, currentFile);
|
|
222
|
-
if (directory) {
|
|
223
|
-
// 新会话默认继承当前 cwd
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
+
},
|
|
227
274
|
});
|
|
228
275
|
if (anchored.cancelled) throw new Error("锚定群会话工作目录已取消。");
|
|
229
276
|
return;
|
|
@@ -232,42 +279,54 @@ async function sendToChatSession(state: SharedFeishuState, chatId: string, promp
|
|
|
232
279
|
},
|
|
233
280
|
});
|
|
234
281
|
if (result.cancelled) throw new Error("创建飞书群专属 Pi 会话已取消。");
|
|
282
|
+
} catch (error) {
|
|
283
|
+
rethrowStaleAsHint(state, error);
|
|
235
284
|
} finally {
|
|
236
285
|
state.switchingSession = false;
|
|
237
286
|
}
|
|
238
287
|
}
|
|
239
288
|
|
|
240
289
|
/**
|
|
241
|
-
*
|
|
242
|
-
*
|
|
243
|
-
*
|
|
244
|
-
*
|
|
245
|
-
* cwd 生效——若未来 Pi 改为丢弃该参数,会得到可操作的错误而不是静默串目录。
|
|
290
|
+
* 把会话文件头(JSONL 首行的 session header)里的 cwd 改写为绑定目录。
|
|
291
|
+
* 群会话空闲时文件不被任何 runtime 持有,重写首行是安全的;下次
|
|
292
|
+
* SessionManager.open 会以新 cwd 重建 runtime(工具、bash 都在新目录执行)。
|
|
293
|
+
* 返回 false 表示文件不可读/格式未知,调用方给出可操作错误。
|
|
246
294
|
*/
|
|
247
|
-
async function
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
if (!directory) {
|
|
254
|
-
return context.switchSession(sessionFile, { withSession });
|
|
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;
|
|
255
301
|
}
|
|
256
|
-
const
|
|
257
|
-
const
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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)) {
|
|
326
|
+
throw new Error(
|
|
327
|
+
`群会话未能切换到绑定目录(当前:${effective})。请重启本地 Pi 后重试,或在本地执行 /feishu status 刷新会话控制。`,
|
|
328
|
+
);
|
|
269
329
|
}
|
|
270
|
-
return result;
|
|
271
330
|
}
|
|
272
331
|
|
|
273
332
|
function safeCwd(context: ExtensionContext): string | undefined {
|