@trim21/personal-pi-extensions 0.1.452 → 0.1.454

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trim21/personal-pi-extensions",
3
- "version": "0.1.452",
3
+ "version": "0.1.454",
4
4
  "type": "module",
5
5
  "description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
6
6
  "keywords": [
package/src/aft/bridge.ts CHANGED
@@ -23,7 +23,7 @@ import {
23
23
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
24
24
 
25
25
  import type { SemanticRemote } from "./config.js";
26
- import { bridgeLogger } from "./logger.js";
26
+ import { type AftLogger, createAftLogger } from "./logger.js";
27
27
 
28
28
  /** Pi 会话 ID:Rust 侧用它做 session 作用域(undo/checkpoint),感知工具可留空。 */
29
29
  export function resolveSessionId(extCtx: ExtensionContext): string | undefined {
@@ -55,6 +55,23 @@ export interface AftPool {
55
55
  projectRoot: string;
56
56
  }
57
57
 
58
+ /** 一次 session 生命周期内的 bridge 状态:日志落盘 + 常驻 aft 子进程。 */
59
+ export interface AftState {
60
+ logger: AftLogger;
61
+ pool: AftPool;
62
+ }
63
+
64
+ /** 创建一份 session 级 bridge 状态;日志落在 tmp/{sessionId}/aft-plugin.log。 */
65
+ export async function createAftState(
66
+ cwd: string,
67
+ sessionId: string | undefined,
68
+ semantic?: SemanticRemote,
69
+ ): Promise<AftState> {
70
+ const logger = createAftLogger(sessionId);
71
+ const pool = await createAftPool(cwd, logger, semantic);
72
+ return { logger, pool };
73
+ }
74
+
58
75
  /** semantic_search 等待索引构建完成的上限(与 Rust 侧 AFT_WAIT_FOR_SEMANTIC_READY_MS 一致)。 */
59
76
  export const SEMANTIC_INDEX_WAIT_TIMEOUT_MS = 600_000;
60
77
 
@@ -79,10 +96,14 @@ export const SEMANTIC_API_KEY_ENV = "AFT_SEMANTIC_API_KEY";
79
96
  * 这个「变量名」,再自己 `env::var` 取值,所以提供值时必须连带把名字告诉它。
80
97
  * 两者都缺省时不注入任何凭据(无鉴权端点直接可用)。
81
98
  */
82
- export async function createAftPool(cwd: string, semantic?: SemanticRemote): Promise<AftPool> {
99
+ export async function createAftPool(
100
+ cwd: string,
101
+ logger: AftLogger,
102
+ semantic?: SemanticRemote,
103
+ ): Promise<AftPool> {
83
104
  // 必须在任何 bridge 代码运行前注册:不设 logger 时 aft-bridge 会把 child
84
105
  // stderr / 生命周期日志 fallback 到 console.error,raw 输出打进 pi 的 stderr 破坏 TUI。
85
- setActiveLogger(bridgeLogger);
106
+ setActiveLogger(logger);
86
107
  const binaryPath = await resolveAftBinary();
87
108
  const paths = resolveCortexKitConfigPaths(cwd);
88
109
  const childEnv: Record<string, string> = {
@@ -114,7 +135,7 @@ export async function createAftPool(cwd: string, semantic?: SemanticRemote): Pro
114
135
  const pool = await createAftTransportPool({
115
136
  harness: "pi",
116
137
  binaryPath,
117
- poolOptions: { childEnv, logger: bridgeLogger },
138
+ poolOptions: { childEnv, logger },
118
139
  configOverrides: {
119
140
  storage_dir: resolveCortexKitStorageRoot(),
120
141
  cortexkit_user_config_path: paths.userConfigPath,
@@ -15,7 +15,13 @@ import { Type } from "typebox";
15
15
  import { type ToolPendant } from "../lib/pendant.js";
16
16
  import { guardWriteAccess } from "../lib/write-guard.js";
17
17
  import { callAftTool } from "./bridge.js";
18
- import { type AftToolContext, bridgeFor, buildPendantMarkdown, resolvePathArg } from "./tools.js";
18
+ import {
19
+ type AftToolContext,
20
+ bridgeFor,
21
+ buildPendantMarkdown,
22
+ compactArgs,
23
+ resolvePathArg,
24
+ } from "./tools.js";
19
25
 
20
26
  /** 工具使用指南,以 markdown 形式维护,读起来像文档。 */
21
27
  const IMPORT_PROMPT = readFileSync(
@@ -101,19 +107,20 @@ export function registerImportTool(pi: ExtensionAPI, ctx: AftToolContext): void
101
107
  const filePath = resolvePathArg(extCtx.cwd, params.path);
102
108
  await guardWriteAccess(extCtx, { toolName: "aft_import", absolutePath: filePath });
103
109
 
104
- const rawArgs: Record<string, unknown> = { op: params.op, path: filePath };
105
- if (params.module !== undefined && params.module.trim() !== "") {
106
- rawArgs.module = params.module;
107
- }
108
- if (params.names !== undefined) rawArgs.names = params.names;
109
- if (params.default_import !== undefined) rawArgs.defaultImport = params.default_import;
110
- if (params.namespace !== undefined) rawArgs.namespace = params.namespace;
111
- if (params.alias !== undefined) rawArgs.alias = params.alias;
112
- if (params.modifiers !== undefined) rawArgs.modifiers = params.modifiers;
113
- if (params.import_kind !== undefined) rawArgs.importKind = params.import_kind;
114
- if (params.remove_name !== undefined) rawArgs.removeName = params.remove_name;
115
- if (params.type_only !== undefined) rawArgs.typeOnly = params.type_only;
116
- if (params.validate !== undefined) rawArgs.validate = params.validate;
110
+ const rawArgs = compactArgs({
111
+ op: params.op,
112
+ path: filePath,
113
+ module: params.module,
114
+ names: params.names,
115
+ defaultImport: params.default_import,
116
+ namespace: params.namespace,
117
+ alias: params.alias,
118
+ modifiers: params.modifiers,
119
+ importKind: params.import_kind,
120
+ removeName: params.remove_name,
121
+ typeOnly: params.type_only,
122
+ validate: params.validate,
123
+ });
117
124
 
118
125
  const { text } = await callAftTool(bridgeFor(ctx), "import", rawArgs, extCtx);
119
126
  return {
package/src/aft/index.ts CHANGED
@@ -9,8 +9,10 @@
9
9
  * fastembed 后端不使用。aft_refactor / aft_import 的 Rust 命令不支持 preview,
10
10
  * 写保护退化为路径级审批。
11
11
  *
12
- * 二进制缺失或 pool 创建失败时降级:不注册任何工具并在 session 开始时报
13
- * 一次错,而不是让每个工具调用失败。
12
+ * bridge 状态(日志 + 常驻 aft 子进程)的生命周期跟 session 走:session_start
13
+ * 时用当次 session id 创建(日志落在 tmp/{sessionId}/aft-plugin.log),
14
+ * session_shutdown / 进程退出时释放。工具实现经 getState() 取状态,
15
+ * session 未初始化时抛错。
14
16
  *
15
17
  * Usage:
16
18
  * pi -e ./aft/index.ts
@@ -19,7 +21,7 @@
19
21
  import { resolveCortexKitConfigPaths } from "@cortexkit/aft-bridge";
20
22
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
21
23
 
22
- import { type AftPool, createAftPool, shutdownAftPool } from "./bridge.js";
24
+ import { createAftState, resolveSessionId, shutdownAftPool } from "./bridge.js";
23
25
  import { loadAftConfig } from "./config.js";
24
26
  import { registerImportTool } from "./imports.js";
25
27
  import { registerRefactorTool } from "./refactor.js";
@@ -30,26 +32,30 @@ import {
30
32
  registerZoomTool,
31
33
  } from "./tools.js";
32
34
 
33
- export default async function aftReadTools(pi: ExtensionAPI): Promise<void> {
35
+ export default function aftReadTools(pi: ExtensionAPI): void {
34
36
  const cwd = process.cwd();
35
37
  const cfg = loadAftConfig(resolveCortexKitConfigPaths(cwd).userConfigPath);
36
38
  if (!cfg.enabled) return;
37
39
 
38
- let pool: AftPool;
39
- try {
40
- pool = await createAftPool(cwd, cfg.semanticRemote);
41
- } catch (error) {
42
- const message = error instanceof Error ? error.message : String(error);
43
- pi.on("session_start", (_event, ctx) => {
44
- ctx.ui.notify(
45
- `AFT tools are disabled: ${message}. Install @cortexkit/aft-<platform> via your npm mirror (locked to the same version as @cortexkit/aft-bridge), then reload.`,
46
- "error",
40
+ // bridge 状态跟 session 生命周期走,作用域就是本工厂闭包,不落到模块级。
41
+ let state: Awaited<ReturnType<typeof createAftState>> | null = null;
42
+
43
+ // 预热:提前解析二进制并拉起 bridge 子进程;失败直接抛给 pi(runner 捕获
44
+ // 后上报 ExtensionError),工具调用侧经 getState() 抛未初始化错误。
45
+ pi.on("session_start", async (_event, ctx) => {
46
+ state = await createAftState(cwd, resolveSessionId(ctx), cfg.semanticRemote);
47
+ });
48
+
49
+ const getState = (): Awaited<ReturnType<typeof createAftState>> => {
50
+ if (!state) {
51
+ throw new Error(
52
+ "AFT is not initialized for this session (no session_start event has fired yet)",
47
53
  );
48
- });
49
- return;
50
- }
54
+ }
55
+ return state;
56
+ };
51
57
 
52
- const toolCtx = { cwd, pool: pool.pool };
58
+ const toolCtx = { cwd, getState };
53
59
  registerOutlineTool(pi, toolCtx);
54
60
  registerZoomTool(pi, toolCtx);
55
61
  registerCallgraphTool(pi, toolCtx);
@@ -69,16 +75,18 @@ export default async function aftReadTools(pi: ExtensionAPI): Promise<void> {
69
75
  }
70
76
  }
71
77
 
72
- // 关闭 bridge pool。session_shutdown 是 pi 的正常生命周期;beforeExit 兜底
73
- // 进程自然退出(不能注册 SIGINT/SIGTERM——那会吞掉 pi 主进程自己的信号处理)。
74
- let shuttingDown = false;
78
+ // 释放当前 session 的 bridge 状态。session_shutdown 是 pi 的正常生命周期;
79
+ // beforeExit 兜底进程自然退出(不能注册 SIGINT/SIGTERM——那会吞掉 pi 主进程
80
+ // 自己的信号处理)。释放后下个 session_start 用新 session id 重建。
75
81
  const shutdown = async (): Promise<void> => {
76
- if (shuttingDown) return;
77
- shuttingDown = true;
82
+ const current = state;
83
+ state = null;
84
+ if (!current) return;
78
85
  try {
79
- await shutdownAftPool(pool);
86
+ await shutdownAftPool(current.pool);
87
+ await current.logger.drain();
80
88
  } catch {
81
- // 关闭失败不影响退出流程
89
+ // 释放失败不影响退出流程
82
90
  }
83
91
  };
84
92
  process.once("beforeExit", () => void shutdown());
package/src/aft/logger.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * AFT 扩展日志:落盘到 agent dir 的 tmp/(<agentDir>/tmp/aft-plugin.log)。
2
+ * AFT 扩展日志:落盘到 agent dir 的 tmp/{sessionId}/(<agentDir>/tmp/<sessionId>/aft-plugin.log)。
3
3
  *
4
4
  * aft-bridge 会把 aft 子进程的 stderr 逐行转发给 logger;不设置 logger 时它
5
5
  * fallback 到 console.error,raw 文本直接打进 pi 进程的 stderr,破坏 TUI。
@@ -15,15 +15,27 @@ const TAG = "[aft-pi]";
15
15
  const FLUSH_INTERVAL_MS = 500;
16
16
  const BUFFER_SIZE_LIMIT = 50;
17
17
 
18
- function logFilePath(): string {
19
- return join(getAgentDir(), "tmp", "aft-plugin.log");
18
+ /** aft-bridge 的 Logger 接口 + drain。 */
19
+ export interface AftLogger {
20
+ log(message: string, meta?: { sessionId?: string }): void;
21
+ warn(message: string, meta?: { sessionId?: string }): void;
22
+ error(message: string, meta?: { sessionId?: string }): void;
23
+ getLogFilePath(): string;
24
+ /** 立即落盘,供测试与关闭路径使用。 */
25
+ drain(): Promise<void>;
20
26
  }
21
27
 
22
- function createLogger() {
28
+ export function createAftLogger(sessionId?: string): AftLogger {
23
29
  let sink: RotatingLogSink | null = null;
24
30
  let buffer: string[] = [];
25
31
  let flushTimer: ReturnType<typeof setTimeout> | null = null;
26
32
 
33
+ function logFilePath(): string {
34
+ return sessionId
35
+ ? join(getAgentDir(), "tmp", sessionId, "aft-plugin.log")
36
+ : join(getAgentDir(), "tmp", "aft-plugin.log");
37
+ }
38
+
27
39
  // sink 懒创建:getAgentDir() 读 PI_CODING_AGENT_DIR,测试里先改 env 再触发写入。
28
40
  function getSink(): RotatingLogSink {
29
41
  sink ??= new RotatingLogSink(logFilePath());
@@ -52,10 +64,10 @@ function createLogger() {
52
64
  }
53
65
  }
54
66
 
55
- function write(level: string, message: string, sessionId?: string): void {
67
+ function write(level: string, message: string, lineSessionId?: string): void {
56
68
  try {
57
69
  const timestamp = new Date().toISOString();
58
- const sessionPrefix = sessionId ? ` [${sessionId}]` : "";
70
+ const sessionPrefix = lineSessionId ? ` [${lineSessionId}]` : "";
59
71
  buffer.push(`[${timestamp}] ${level} ${TAG}${sessionPrefix} ${message}\n`);
60
72
  if (buffer.length >= BUFFER_SIZE_LIMIT) {
61
73
  flush();
@@ -68,9 +80,15 @@ function createLogger() {
68
80
  }
69
81
 
70
82
  return {
71
- log: (message: string, sessionId?: string): void => write("INFO", message, sessionId),
72
- warn: (message: string, sessionId?: string): void => write("WARN", message, sessionId),
73
- error: (message: string, sessionId?: string): void => write("ERROR", message, sessionId),
83
+ log(message, meta) {
84
+ write("INFO", message, meta?.sessionId);
85
+ },
86
+ warn(message, meta) {
87
+ write("WARN", message, meta?.sessionId);
88
+ },
89
+ error(message, meta) {
90
+ write("ERROR", message, meta?.sessionId);
91
+ },
74
92
  getLogFilePath: logFilePath,
75
93
  drain: async (): Promise<void> => {
76
94
  flush();
@@ -78,40 +96,3 @@ function createLogger() {
78
96
  },
79
97
  };
80
98
  }
81
-
82
- const logger = createLogger();
83
-
84
- export function log(message: string): void {
85
- logger.log(message);
86
- }
87
-
88
- export function warn(message: string): void {
89
- logger.warn(message);
90
- }
91
-
92
- export function error(message: string): void {
93
- logger.error(message);
94
- }
95
-
96
- export function getLogFilePath(): string {
97
- return logger.getLogFilePath();
98
- }
99
-
100
- /** aft-bridge 的 Logger 适配:child stderr / bridge 生命周期日志都走这里。 */
101
- export const bridgeLogger = {
102
- log(message: string, meta?: { sessionId?: string }): void {
103
- logger.log(message, meta?.sessionId);
104
- },
105
- warn(message: string, meta?: { sessionId?: string }): void {
106
- logger.warn(message, meta?.sessionId);
107
- },
108
- error(message: string, meta?: { sessionId?: string }): void {
109
- logger.error(message, meta?.sessionId);
110
- },
111
- getLogFilePath: (): string => logger.getLogFilePath(),
112
- };
113
-
114
- /** 立即落盘,供测试与关闭路径使用。 */
115
- export async function drainLogger(): Promise<void> {
116
- await logger.drain();
117
- }
@@ -17,7 +17,13 @@ import { Type } from "typebox";
17
17
  import { type ToolPendant } from "../lib/pendant.js";
18
18
  import { guardWriteAccess } from "../lib/write-guard.js";
19
19
  import { callAftTool } from "./bridge.js";
20
- import { type AftToolContext, bridgeFor, buildPendantMarkdown, resolvePathArg } from "./tools.js";
20
+ import {
21
+ type AftToolContext,
22
+ bridgeFor,
23
+ buildPendantMarkdown,
24
+ compactArgs,
25
+ resolvePathArg,
26
+ } from "./tools.js";
21
27
 
22
28
  /** 工具使用指南,以 markdown 形式维护,读起来像文档。 */
23
29
  const REFACTOR_PROMPT = readFileSync(
@@ -118,20 +124,17 @@ export function registerRefactorTool(pi: ExtensionAPI, ctx: AftToolContext): voi
118
124
  await guardWriteAccess(extCtx, { toolName: "aft_refactor", absolutePath: target });
119
125
  }
120
126
 
121
- const rawArgs: Record<string, unknown> = { op: params.op, path: filePath };
122
- if (params.symbol !== undefined && params.symbol.trim() !== "") {
123
- rawArgs.symbol = params.symbol;
124
- }
125
- if (destination !== undefined) rawArgs.destination = destination;
126
- if (params.scope !== undefined && params.scope.trim() !== "") {
127
- rawArgs.scope = params.scope;
128
- }
129
- if (params.name !== undefined && params.name.trim() !== "") {
130
- rawArgs.name = params.name;
131
- }
132
- if (startLine !== undefined) rawArgs.startLine = startLine;
133
- if (endLine !== undefined) rawArgs.endLine = endLine;
134
- if (callSiteLine !== undefined) rawArgs.callSiteLine = callSiteLine;
127
+ const rawArgs = compactArgs({
128
+ op: params.op,
129
+ path: filePath,
130
+ symbol: params.symbol,
131
+ destination,
132
+ scope: params.scope,
133
+ name: params.name,
134
+ startLine,
135
+ endLine,
136
+ callSiteLine,
137
+ });
135
138
 
136
139
  const { text } = await callAftTool(bridgeFor(ctx), "refactor", rawArgs, extCtx);
137
140
  return {
package/src/aft/tools.ts CHANGED
@@ -13,7 +13,6 @@ import { fileURLToPath } from "node:url";
13
13
 
14
14
  import {
15
15
  type AftProjectTransport,
16
- type AftTransportPool,
17
16
  coerceBoolean,
18
17
  coerceOptionalInt,
19
18
  coerceTargetParam,
@@ -25,7 +24,7 @@ import { Type } from "typebox";
25
24
 
26
25
  import { formatDisplayPath, formatSubtitlePath } from "../lib/path.js";
27
26
  import { type ToolPendant } from "../lib/pendant.js";
28
- import { callAftTool, SEMANTIC_INDEX_WAIT_TIMEOUT_MS } from "./bridge.js";
27
+ import { type AftState, callAftTool, SEMANTIC_INDEX_WAIT_TIMEOUT_MS } from "./bridge.js";
29
28
 
30
29
  /** 工具使用指南,以 markdown 形式维护,读起来像文档。 */
31
30
  const OUTLINE_PROMPT = readFileSync(
@@ -53,11 +52,25 @@ export function resolvePathArg(cwd: string, input: string): string {
53
52
 
54
53
  export interface AftToolContext {
55
54
  cwd: string;
56
- pool: AftTransportPool;
55
+ /** 当前 session 的 bridge 状态;session 未初始化时抛错。 */
56
+ getState(): AftState;
57
57
  }
58
58
 
59
59
  export function bridgeFor(ctx: AftToolContext): AftProjectTransport {
60
- return ctx.pool.getBridge(ctx.cwd);
60
+ return ctx.getState().pool.pool.getBridge(ctx.cwd);
61
+ }
62
+
63
+ /**
64
+ * 丢弃 undefined 与空白字符串字段:bridge 参数经 JSON.stringify 发给 Rust 侧,
65
+ * 「缺 key」与「空字符串」在 Rust 端语义不同,这里把空串统一归一成缺省。
66
+ * false / 空数组等是合法取值,保留。
67
+ */
68
+ export function compactArgs(args: Record<string, unknown>): Record<string, unknown> {
69
+ return Object.fromEntries(
70
+ Object.entries(args).filter(
71
+ ([, value]) => value !== undefined && !(typeof value === "string" && value.trim() === ""),
72
+ ),
73
+ );
61
74
  }
62
75
 
63
76
  /** 人类视角的调用记录:input params + 与 LLM 相同的输出结果。 */
@@ -132,11 +145,11 @@ export function registerOutlineTool(pi: ExtensionAPI, ctx: AftToolContext): void
132
145
  const stats = await stat(resolved).catch(() => null);
133
146
  filesMode = stats?.isDirectory() ?? false;
134
147
  }
135
- const rawArgs: Record<string, unknown> = {
148
+ const rawArgs = compactArgs({
136
149
  target: filesMode ? target : resolved,
137
- };
138
- if (filesMode) rawArgs.files = true;
139
- if (params.includeTests !== undefined) rawArgs.includeTests = params.includeTests;
150
+ files: filesMode ? true : undefined,
151
+ includeTests: params.includeTests,
152
+ });
140
153
 
141
154
  const subtitle = buildOutlineSubtitle(extCtx.cwd, target);
142
155
 
@@ -198,19 +211,17 @@ export function registerZoomTool(pi: ExtensionAPI, ctx: AftToolContext): void {
198
211
  promptGuidelines: [ZOOM_PROMPT],
199
212
  parameters: ZoomParams,
200
213
  async execute(_id, params, _signal, _onUpdate, extCtx) {
201
- const rawArgs: Record<string, unknown> = {
214
+ const rawArgs = compactArgs({
202
215
  filePath: resolvePathArg(extCtx.cwd, params.path),
203
- ...(params.symbols && { symbols: params.symbols }),
204
- };
205
-
206
- const contextLines = coerceOptionalInt(
207
- params.contextLines,
208
- "contextLines",
209
- 1,
210
- Number.MAX_SAFE_INTEGER,
211
- );
212
- if (contextLines !== undefined) rawArgs.contextLines = contextLines;
213
- if (coerceBoolean(params.callgraph)) rawArgs.callgraph = true;
216
+ symbols: params.symbols,
217
+ contextLines: coerceOptionalInt(
218
+ params.contextLines,
219
+ "contextLines",
220
+ 1,
221
+ Number.MAX_SAFE_INTEGER,
222
+ ),
223
+ callgraph: coerceBoolean(params.callgraph) ? true : undefined,
224
+ });
214
225
 
215
226
  const subtitle = buildZoomSubtitle(extCtx.cwd, params);
216
227
 
@@ -305,26 +316,17 @@ export function registerCallgraphTool(pi: ExtensionAPI, ctx: AftToolContext): vo
305
316
  promptGuidelines: [CALLGRAPH_PROMPT],
306
317
  parameters: CallgraphParams,
307
318
  async execute(_id, params, _signal, _onUpdate, extCtx) {
308
- const rawArgs: Record<string, unknown> = {
319
+ const rawArgs = compactArgs({
309
320
  op: params.op,
310
321
  filePath: resolvePathArg(extCtx.cwd, params.path),
311
322
  symbol: params.symbol,
312
- };
313
- const depth = coerceOptionalInt(params.depth, "depth", 1, Number.MAX_SAFE_INTEGER);
314
- if (depth !== undefined) rawArgs.depth = depth;
315
- if (params.expression !== undefined && params.expression !== "") {
316
- rawArgs.expression = params.expression;
317
- }
318
- if (params.toSymbol !== undefined && params.toSymbol !== "") {
319
- rawArgs.toSymbol = params.toSymbol;
320
- }
321
- if (params.toPath !== undefined && params.toPath !== "") {
322
- rawArgs.toFile = resolvePathArg(extCtx.cwd, params.toPath);
323
- }
324
- if (params.includeTests !== undefined) rawArgs.includeTests = params.includeTests;
325
- if (params.includeUnresolved !== undefined) {
326
- rawArgs.includeUnresolved = params.includeUnresolved;
327
- }
323
+ depth: coerceOptionalInt(params.depth, "depth", 1, Number.MAX_SAFE_INTEGER),
324
+ expression: params.expression,
325
+ toSymbol: params.toSymbol,
326
+ toFile: params.toPath ? resolvePathArg(extCtx.cwd, params.toPath) : undefined,
327
+ includeTests: params.includeTests,
328
+ includeUnresolved: params.includeUnresolved,
329
+ });
328
330
 
329
331
  const { text, response } = await callAftTool(
330
332
  bridgeFor(ctx),
@@ -398,12 +400,12 @@ export function registerSearchTool(pi: ExtensionAPI, ctx: AftToolContext): void
398
400
  if (typeof params.query !== "string" || params.query.trim().length === 0) {
399
401
  throw new Error("'query' must be a non-empty string");
400
402
  }
401
- const rawArgs: Record<string, unknown> = { query: params.query };
402
- if (params.topK !== undefined) rawArgs.topK = params.topK;
403
- if (params.includeTests !== undefined) rawArgs.includeTests = params.includeTests;
404
- if (params.path !== undefined && params.path !== "") {
405
- rawArgs.path = resolvePathArg(extCtx.cwd, params.path);
406
- }
403
+ const rawArgs = compactArgs({
404
+ query: params.query,
405
+ topK: params.topK,
406
+ includeTests: params.includeTests,
407
+ path: params.path ? resolvePathArg(extCtx.cwd, params.path) : undefined,
408
+ });
407
409
 
408
410
  const { text, response } = await callAftTool(bridgeFor(ctx), "search", rawArgs, extCtx, {
409
411
  // 默认 search 传输超时仅 60s,会早于索引等待(600s)触发;覆盖为等待
@@ -31,6 +31,18 @@ const FILE_CHANGE_CHANGED = 2;
31
31
  const FILE_CHANGE_DELETED = 3;
32
32
  const TEXT_DOCUMENT_SYNC_INCREMENTAL = 2;
33
33
 
34
+ /** LSP WatchKind 位掩码(FileSystemWatcher.kind,缺省 create|change|delete)。 */
35
+ export const WATCH_KIND_CREATE = 1;
36
+ export const WATCH_KIND_CHANGE = 2;
37
+ export const WATCH_KIND_DELETE = 4;
38
+ const WATCH_KIND_ALL = WATCH_KIND_CREATE | WATCH_KIND_CHANGE | WATCH_KIND_DELETE;
39
+
40
+ /** 服务器通过 client/registerCapability 注册的单个 watcher。 */
41
+ export interface WatcherGlob {
42
+ pattern: string;
43
+ kind: number;
44
+ }
45
+
34
46
  const FILE_CHANGE_TYPE: Record<FileChangeType, number> = {
35
47
  created: FILE_CHANGE_CREATED,
36
48
  changed: FILE_CHANGE_CHANGED,
@@ -77,7 +89,7 @@ interface CapabilityRegistration {
77
89
  registerOptions?: {
78
90
  identifier?: string;
79
91
  workspaceDiagnostics?: boolean;
80
- watchers?: { globPattern?: string }[];
92
+ watchers?: { globPattern?: string; kind?: number }[];
81
93
  };
82
94
  }
83
95
 
@@ -115,8 +127,8 @@ export interface LspClient {
115
127
  /** 把工作区文件事件批量通知服务器;驻留文档不在此通道(走 didOpen/didChange/退场)。 */
116
128
  watchedFiles(changes: FileChange[]): Promise<void>;
117
129
  };
118
- /** 服务器注册的 workspace/didChangeWatchedFiles watchers glob(去重)。 */
119
- watchPatterns(): string[];
130
+ /** 服务器注册的 workspace/didChangeWatchedFiles watchers(pattern + kind,按 pattern+kind 去重)。 */
131
+ watchers(): WatcherGlob[];
120
132
  readonly diagnostics: Map<string, Diagnostic[]>;
121
133
  waitForDiagnostics(request: {
122
134
  path: string;
@@ -247,8 +259,8 @@ export async function create(input: CreateInput): Promise<LspClient> {
247
259
  const pullDiagnostics = new Map<string, Diagnostic[]>();
248
260
  const published = new Map<string, { at: number; version?: number }>();
249
261
  const diagnosticRegistrations = new Map<string, CapabilityRegistration>();
250
- /** registration id → workspace/didChangeWatchedFiles watchers glob。 */
251
- const watcherRegistrations = new Map<string, string[]>();
262
+ /** registration id → workspace/didChangeWatchedFiles watchers(pattern + WatchKind 位)。 */
263
+ const watcherRegistrations = new Map<string, WatcherGlob[]>();
252
264
  const registrationListeners = new Set<() => void>();
253
265
  const diagnosticListeners = new Set<(input: { path: string; serverID: string }) => void>();
254
266
  /** resolvedPath → 客户端已发送的最新文档版本(didOpen=0,didChange 递增)。 */
@@ -307,8 +319,11 @@ export async function create(input: CreateInput): Promise<LspClient> {
307
319
  if (registration.method === "workspace/didChangeWatchedFiles") {
308
320
  const watchers =
309
321
  registration.registerOptions?.watchers
310
- ?.map((watcher) => watcher.globPattern)
311
- .filter((pattern): pattern is string => typeof pattern === "string") ?? [];
322
+ ?.map((watcher) => ({
323
+ pattern: watcher.globPattern,
324
+ kind: watcher.kind ?? WATCH_KIND_ALL,
325
+ }))
326
+ .filter((watcher): watcher is WatcherGlob => typeof watcher.pattern === "string") ?? [];
312
327
  watcherRegistrations.set(registration.id, watchers);
313
328
  } else if (registration.method === "textDocument/diagnostic") {
314
329
  diagnosticRegistrations.set(registration.id, registration);
@@ -754,8 +769,15 @@ export async function create(input: CreateInput): Promise<LspClient> {
754
769
  get serverID() {
755
770
  return input.serverID;
756
771
  },
757
- watchPatterns(): string[] {
758
- return [...new Set([...watcherRegistrations.values()].flat())];
772
+ watchers(): WatcherGlob[] {
773
+ return [
774
+ ...new Set(
775
+ [...watcherRegistrations.values()].flat().map((w) => JSON.stringify([w.pattern, w.kind])),
776
+ ),
777
+ ].map((key) => {
778
+ const [pattern, kind] = JSON.parse(key) as [string, number];
779
+ return { pattern, kind };
780
+ });
759
781
  },
760
782
  get connection() {
761
783
  return connection;
@@ -27,7 +27,15 @@ import { type Static, Type } from "typebox";
27
27
  import { Value } from "typebox/value";
28
28
 
29
29
  import { type LspServerAdapter } from "./adapter.js";
30
- import { create, type CreateInput, type Diagnostic, type Info as LspClient } from "./client.js";
30
+ import {
31
+ create,
32
+ type CreateInput,
33
+ type Diagnostic,
34
+ type Info as LspClient,
35
+ WATCH_KIND_CHANGE,
36
+ WATCH_KIND_CREATE,
37
+ WATCH_KIND_DELETE,
38
+ } from "./client.js";
31
39
  import { report } from "./diagnostic.js";
32
40
  import { createAdapters, mergeServerRecords, serverConfigSchema } from "./server-config.js";
33
41
  import { type FileChange, watchWorkspace, type WorkspaceWatcher } from "./watcher.js";
@@ -346,10 +354,26 @@ export function createLspService(
346
354
  if (!containsPath(change.path, cwd) || !containsPath(change.path, client.root)) {
347
355
  return false;
348
356
  }
349
- const patterns = client.watchPatterns();
350
- if (patterns.length > 0) {
351
- const candidate = relative(client.root, change.path).split(sep).join("/");
352
- if (patterns.every((pattern) => !minimatch(candidate, pattern))) return false;
357
+ const watchers = client.watchers();
358
+ if (watchers.length > 0) {
359
+ // pattern 可能是相对 glob(pyright 的 "**")也可能是绝对 glob
360
+ // (gopls 的 "/workspace/**/*.{go,mod}"),两个候选任一命中即转发;
361
+ // 并按各 watcher 注册的 WatchKind 位过滤事件类型
362
+ const relativeCandidate = relative(client.root, change.path).split(sep).join("/");
363
+ const absoluteCandidate = change.path.split(sep).join("/");
364
+ const kindBit =
365
+ change.type === "created"
366
+ ? WATCH_KIND_CREATE
367
+ : change.type === "changed"
368
+ ? WATCH_KIND_CHANGE
369
+ : WATCH_KIND_DELETE;
370
+ const matched = watchers.some(
371
+ (watcher) =>
372
+ (watcher.kind & kindBit) !== 0 &&
373
+ (minimatch(relativeCandidate, watcher.pattern) ||
374
+ minimatch(absoluteCandidate, watcher.pattern)),
375
+ );
376
+ if (!matched) return false;
353
377
  }
354
378
  const extensions = state.clientExtensions.get(client);
355
379
  return (