@trim21/personal-pi-extensions 0.1.452 → 0.1.456
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 +3 -1
- package/src/aft/bridge.ts +25 -4
- package/src/aft/imports.ts +21 -14
- package/src/aft/index.ts +32 -24
- package/src/aft/logger.ts +27 -46
- package/src/aft/refactor.ts +18 -15
- package/src/aft/tools.ts +45 -43
- package/src/bwrap/holder.js +4 -13
- package/src/bwrap/holder.ts +12 -23
- package/src/bwrap/mihomo-config.ts +7 -0
- package/src/bwrap/network-stack.ts +67 -8
- package/src/lib/lsp/client.ts +31 -9
- package/src/lib/lsp/diagnostic.ts +2 -2
- package/src/lib/lsp/lsp.ts +29 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@trim21/personal-pi-extensions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.456",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
|
|
6
6
|
"keywords": [
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
"test": "vitest run",
|
|
25
25
|
"test:coverage": "vitest run --coverage",
|
|
26
26
|
"sandbox": "tsx bin/sandbox.ts",
|
|
27
|
+
"aft-search": "tsx bin/aft-search.ts",
|
|
27
28
|
"build:holder": "esbuild src/bwrap/holder.ts --format=esm --target=node24 --outfile=src/bwrap/holder.js",
|
|
28
29
|
"prepare": "husky"
|
|
29
30
|
},
|
|
@@ -43,6 +44,7 @@
|
|
|
43
44
|
"@types/turndown": "^5.0.6",
|
|
44
45
|
"@typescript/native": "npm:typescript@^7.0.2",
|
|
45
46
|
"@vitest/coverage-v8": "^4.1.10",
|
|
47
|
+
"citty": "^0.2.2",
|
|
46
48
|
"esbuild": "^0.28.2",
|
|
47
49
|
"eslint": "^10.8.1",
|
|
48
50
|
"eslint-config-prettier": "10.1.8",
|
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 {
|
|
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(
|
|
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(
|
|
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
|
|
138
|
+
poolOptions: { childEnv, logger },
|
|
118
139
|
configOverrides: {
|
|
119
140
|
storage_dir: resolveCortexKitStorageRoot(),
|
|
120
141
|
cortexkit_user_config_path: paths.userConfigPath,
|
package/src/aft/imports.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
*
|
|
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 {
|
|
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
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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,
|
|
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
|
-
//
|
|
73
|
-
//
|
|
74
|
-
|
|
78
|
+
// 释放当前 session 的 bridge 状态。session_shutdown 是 pi 的正常生命周期;
|
|
79
|
+
// beforeExit 兜底进程自然退出(不能注册 SIGINT/SIGTERM——那会吞掉 pi 主进程
|
|
80
|
+
// 自己的信号处理)。释放后下个 session_start 用新 session id 重建。
|
|
75
81
|
const shutdown = async (): Promise<void> => {
|
|
76
|
-
|
|
77
|
-
|
|
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
|
|
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
|
-
|
|
19
|
-
|
|
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
|
|
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,
|
|
67
|
+
function write(level: string, message: string, lineSessionId?: string): void {
|
|
56
68
|
try {
|
|
57
69
|
const timestamp = new Date().toISOString();
|
|
58
|
-
const sessionPrefix =
|
|
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
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
}
|
package/src/aft/refactor.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
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
|
|
148
|
+
const rawArgs = compactArgs({
|
|
136
149
|
target: filesMode ? target : resolved,
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
|
214
|
+
const rawArgs = compactArgs({
|
|
202
215
|
filePath: resolvePathArg(extCtx.cwd, params.path),
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
|
319
|
+
const rawArgs = compactArgs({
|
|
309
320
|
op: params.op,
|
|
310
321
|
filePath: resolvePathArg(extCtx.cwd, params.path),
|
|
311
322
|
symbol: params.symbol,
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
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)触发;覆盖为等待
|
package/src/bwrap/holder.js
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
-
import { readFile } from "node:fs/promises";
|
|
3
2
|
import { setTimeout as sleep } from "node:timers/promises";
|
|
4
3
|
const args = process.argv.slice(2);
|
|
5
4
|
const configBase64 = args[0];
|
|
6
5
|
const mihomoPath = args[1];
|
|
7
|
-
const
|
|
8
|
-
const
|
|
9
|
-
if (!configBase64 || !mihomoPath || !
|
|
6
|
+
const tunMtu = Number(args[2]);
|
|
7
|
+
const mihomoHome = args[3];
|
|
8
|
+
if (!configBase64 || !mihomoPath || !tunMtu || !mihomoHome) {
|
|
10
9
|
process.exit(2);
|
|
11
10
|
}
|
|
12
11
|
process.chdir("/");
|
|
@@ -27,16 +26,8 @@ async function waitFor(attempts, delayMs, predicate) {
|
|
|
27
26
|
async function main() {
|
|
28
27
|
process.stdin.resume();
|
|
29
28
|
process.stdin.on("end", () => process.exit(0));
|
|
30
|
-
const status = await readFile("/proc/self/status", "utf8");
|
|
31
|
-
const hostPid = /^NSpid:\s+(.+)$/m.exec(status)?.[1].split(/\s+/, 1)[0];
|
|
32
|
-
if (!hostPid) {
|
|
33
|
-
throw new Error("Failed to resolve host pid from NSpid");
|
|
34
|
-
}
|
|
35
|
-
spawn(slirp4netnsPath, ["-c", `--mtu=${tunMtu}`, "--netns-type=pid", hostPid, "tap0"], {
|
|
36
|
-
stdio: ["ignore", "inherit", "inherit"]
|
|
37
|
-
});
|
|
38
29
|
await waitFor(100, 100, tap0Exists);
|
|
39
|
-
const mihomo = spawn(mihomoPath, ["-config", configBase64], {
|
|
30
|
+
const mihomo = spawn(mihomoPath, ["-d", mihomoHome, "-config", configBase64], {
|
|
40
31
|
stdio: ["ignore", "inherit", "inherit"]
|
|
41
32
|
});
|
|
42
33
|
const stop = () => {
|
package/src/bwrap/holder.ts
CHANGED
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
/* eslint-disable no-console, unicorn/no-process-exit -- 独立 CLI 脚本,由 unshare 直接执行 */
|
|
2
2
|
import { spawn } from "node:child_process";
|
|
3
|
-
import { readFile } from "node:fs/promises";
|
|
4
3
|
import { setTimeout as sleep } from "node:timers/promises";
|
|
5
4
|
|
|
6
|
-
// 用法:unshare -Urnp --fork --kill-child node holder.ts <config-base64> <mihomo> <
|
|
5
|
+
// 用法:unshare -Urnp --fork --kill-child node holder.ts <config-base64> <mihomo> <mtu> <mihomo-home-dir>
|
|
7
6
|
// 本进程是 pid namespace 的 init:无论以何种方式退出(含 SIGKILL),内核都会清理该 pid ns 内的
|
|
8
|
-
//
|
|
7
|
+
// mihomo,userns/netns 引用随之归零。slirp4netns 由宿主侧启动(egress 必须留在宿主 netns,
|
|
8
|
+
// 否则出站流量会绕回 mihomo 的 TUN 被 dns-hijack 自劫持),它通过 exit-fd 绑定本进程的生命周期:
|
|
9
|
+
// fd 3 是 exit-fd 的写端(network-stack 经 unshare 传入),本进程存活期间保持打开,死亡即关闭,
|
|
10
|
+
// slirp4netns 侧收到 HUP 自行退出并释放 tap fd,netns 引用随之归零。
|
|
9
11
|
const args = process.argv.slice(2);
|
|
10
12
|
const configBase64 = args[0];
|
|
11
13
|
const mihomoPath = args[1];
|
|
12
|
-
const
|
|
13
|
-
const
|
|
14
|
-
if (!configBase64 || !mihomoPath || !
|
|
14
|
+
const tunMtu = Number(args[2]);
|
|
15
|
+
const mihomoHome = args[3];
|
|
16
|
+
if (!configBase64 || !mihomoPath || !tunMtu || !mihomoHome) {
|
|
15
17
|
process.exit(2);
|
|
16
18
|
}
|
|
17
19
|
|
|
@@ -40,28 +42,15 @@ async function waitFor(
|
|
|
40
42
|
|
|
41
43
|
async function main(): Promise<void> {
|
|
42
44
|
// 宿主(pi)进程死亡检测:宿主以任何方式退出(含 SIGKILL)都会关闭它持有的 stdin 写端,
|
|
43
|
-
// 这里读到 EOF 即自杀(init 退出即触发内核清理整个 pid ns
|
|
45
|
+
// 这里读到 EOF 即自杀(init 退出即触发内核清理整个 pid ns,顺带关闭 exit-fd 写端)。
|
|
44
46
|
process.stdin.resume();
|
|
45
47
|
process.stdin.on("end", () => process.exit(0));
|
|
46
48
|
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
const status = await readFile("/proc/self/status", "utf8");
|
|
50
|
-
const hostPid = /^NSpid:\s+(.+)$/m.exec(status)?.[1].split(/\s+/, 1)[0];
|
|
51
|
-
if (!hostPid) {
|
|
52
|
-
throw new Error("Failed to resolve host pid from NSpid");
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
// slirp4netns 提供 egress:本进程已在目标 userns/netns 内,setns 到自身 netns(no-op);
|
|
56
|
-
// 日志 inherit 到 holder 的 stdout/stderr,宿主侧诊断(--verbose)可见
|
|
57
|
-
spawn(slirp4netnsPath, ["-c", `--mtu=${tunMtu}`, "--netns-type=pid", hostPid, "tap0"], {
|
|
58
|
-
stdio: ["ignore", "inherit", "inherit"],
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
// mihomo 日志透传到 holder 的 stdout/stderr,宿主侧监听 "Tun adapter listening" 判定就绪
|
|
49
|
+
// tap0 由宿主侧的 slirp4netns(fork 进本 netns)创建,就绪后再拉起 mihomo。
|
|
50
|
+
// mihomo 日志透传到 holder 的 stdout/stderr,宿主侧监听 "Tun adapter listening" 判定就绪。
|
|
62
51
|
await waitFor(100, 100, tap0Exists);
|
|
63
52
|
|
|
64
|
-
const mihomo = spawn(mihomoPath, ["-config", configBase64], {
|
|
53
|
+
const mihomo = spawn(mihomoPath, ["-d", mihomoHome, "-config", configBase64], {
|
|
65
54
|
stdio: ["ignore", "inherit", "inherit"],
|
|
66
55
|
});
|
|
67
56
|
|
|
@@ -20,6 +20,8 @@ export interface MihomoConfig {
|
|
|
20
20
|
mode: "rule";
|
|
21
21
|
"log-level": "info";
|
|
22
22
|
ipv6: false;
|
|
23
|
+
/** 出站静态绑定 slirp 接口(固定名 tap0)。 */
|
|
24
|
+
"interface-name": string;
|
|
23
25
|
dns: {
|
|
24
26
|
enable: true;
|
|
25
27
|
ipv6: false;
|
|
@@ -173,6 +175,11 @@ export function generateMihomoConfig(options: MihomoConfigOptions): MihomoConfig
|
|
|
173
175
|
mode: "rule",
|
|
174
176
|
"log-level": "info",
|
|
175
177
|
ipv6: false,
|
|
178
|
+
// 静态绑定 tap0:auto-detect-interface 在启动瞬间可能探测到未就绪的接口
|
|
179
|
+
//(tap0 尚由 slirp4netns 创建),monitor 事后纠正但 DNS 拨号已经走错接口,
|
|
180
|
+
// 上游查询进自己的 TUN 被 dns-hijack 自劫持形成回环(allowlist 域名全部
|
|
181
|
+
// SERVFAIL)。slirp 接口名固定是 tap0,直接写死最可靠。
|
|
182
|
+
"interface-name": "tap0",
|
|
176
183
|
dns: {
|
|
177
184
|
enable: true,
|
|
178
185
|
ipv6: false,
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { type ChildProcess, spawn } from "node:child_process";
|
|
2
|
-
import {
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { mkdir, readFile, readlink } from "node:fs/promises";
|
|
4
|
+
import { join } from "node:path";
|
|
3
5
|
import { fileURLToPath } from "node:url";
|
|
4
6
|
|
|
7
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
|
|
5
9
|
import { forEachLine } from "../lib/proc.js";
|
|
6
10
|
import { generateMihomoConfig, TUN_MTU } from "./mihomo-config.js";
|
|
7
11
|
|
|
@@ -178,12 +182,15 @@ export interface NetworkStack {
|
|
|
178
182
|
|
|
179
183
|
interface NetworkStackState {
|
|
180
184
|
holderPid: number;
|
|
185
|
+
slirpPid: number;
|
|
181
186
|
}
|
|
182
187
|
|
|
183
188
|
/** 兜底:调用方忘记 stop() 时,对象被 GC 回收后 kill 残留进程。 */
|
|
184
189
|
const stackFinalizer = new FinalizationRegistry<NetworkStackState>((state) => {
|
|
185
|
-
// SIGTERM 经 unshare --kill-child 转发给 init,内核清理 pid ns
|
|
190
|
+
// SIGTERM 经 unshare --kill-child 转发给 init,内核清理 pid ns 内全部进程;
|
|
191
|
+
// slirp4netns 在宿主侧持有 tap fd,单独终止
|
|
186
192
|
killProcess(state.holderPid);
|
|
193
|
+
killProcess(state.slirpPid);
|
|
187
194
|
});
|
|
188
195
|
|
|
189
196
|
/**
|
|
@@ -203,10 +210,17 @@ export async function startNetworkStack(options: NetworkStackOptions): Promise<N
|
|
|
203
210
|
const config = generateMihomoConfig({ allowlist, dnsServers });
|
|
204
211
|
const configBase64 = Buffer.from(JSON.stringify(config)).toString("base64");
|
|
205
212
|
|
|
213
|
+
// mihomo 工作目录(-d):cache.db 等落在这里,而不是它默认的 ~/.config/mihomo/
|
|
214
|
+
//(后者不存在时 mihomo 每次启动都告警且 fakeip 映射无持久化)。每次启动用独立
|
|
215
|
+
// uuid 目录,避免并发的多个 holder 争抢 bbolt 文件锁。
|
|
216
|
+
const mihomoHome = join(getAgentDir(), "tmp", `mihomo-${randomUUID()}`);
|
|
217
|
+
await mkdir(mihomoHome, { recursive: true });
|
|
218
|
+
|
|
206
219
|
let holder: ChildProcess | undefined;
|
|
220
|
+
let slirp: ChildProcess | undefined;
|
|
207
221
|
try {
|
|
208
222
|
// unshare -p --fork:node 成为 pid namespace 的 init,任何方式退出(含 SIGKILL)
|
|
209
|
-
// 内核都会清理 pid ns 内全部进程(
|
|
223
|
+
// 内核都会清理 pid ns 内全部进程(mihomo),ns 引用随之归零;
|
|
210
224
|
// --kill-child=SIGTERM:宿主侧 SIGTERM unshare 时转发给 init 走优雅退出
|
|
211
225
|
holder = spawn(
|
|
212
226
|
"unshare",
|
|
@@ -219,8 +233,8 @@ export async function startNetworkStack(options: NetworkStackOptions): Promise<N
|
|
|
219
233
|
HOLDER_PATH,
|
|
220
234
|
configBase64,
|
|
221
235
|
mihomoPath,
|
|
222
|
-
slirp4netnsPath,
|
|
223
236
|
String(TUN_MTU),
|
|
237
|
+
mihomoHome,
|
|
224
238
|
],
|
|
225
239
|
{
|
|
226
240
|
env: {
|
|
@@ -228,8 +242,9 @@ export async function startNetworkStack(options: NetworkStackOptions): Promise<N
|
|
|
228
242
|
PATH: `${process.env.PATH ?? ""}:${SBIN_PATH_SUFFIX}`,
|
|
229
243
|
},
|
|
230
244
|
// stdin 保持 pipe:本进程持有写端,进程退出(含 SIGKILL)时内核关闭 fd,
|
|
231
|
-
// holder 读到 EOF 即自杀(init 退出 → 内核清理 pid ns
|
|
232
|
-
|
|
245
|
+
// holder 读到 EOF 即自杀(init 退出 → 内核清理 pid ns)。
|
|
246
|
+
// fd 3:exit-fd 写端,传给 holder 持有(holder 死亡 → 读端 HUP → slirp4netns 退出)。
|
|
247
|
+
stdio: ["pipe", "pipe", "pipe", "pipe"],
|
|
233
248
|
},
|
|
234
249
|
);
|
|
235
250
|
forwardOutput(holder, holderOutput);
|
|
@@ -239,9 +254,45 @@ export async function startNetworkStack(options: NetworkStackOptions): Promise<N
|
|
|
239
254
|
const holderPid = holder.pid;
|
|
240
255
|
await waitForNewUserns(holderPid);
|
|
241
256
|
|
|
257
|
+
// slirp4netns 提供 egress,必须在宿主 netns 启动:它的 egress socket 决定出站
|
|
258
|
+
// 视角,留在沙盒 netns 里会被 mihomo 的 TUN 策略路由 + dns-hijack 自劫持成环
|
|
259
|
+
//(上游 DNS 查询自己劫自己,allowlist 域名全部 SERVFAIL)。tap fd 会被它持有
|
|
260
|
+
// 而 pin 住 netns,因此用 exit-fd(holder 的 fd 3 写端的读端)绑定 holder 生命
|
|
261
|
+
// 周期:holder 退出 → 读端 HUP → slirp4netns 退出 → tap fd 释放。
|
|
262
|
+
// --userns-path / --netns-type=pid 都指向 unshare 进程:它创建并持有目标
|
|
263
|
+
// userns/netns(node holder 是它的子进程,同 ns)。
|
|
264
|
+
// exit-fd 的读端 fd:从 holder 的额外 stdio pipe 取父进程侧的原始 fd。
|
|
265
|
+
// Node 没有公开 API 拿它(stdio[3].fd 恒为 undefined,只能读 _handle,且仅在
|
|
266
|
+
// 子进程存活期间有效);pipe 要跨两个子进程共享(holder 持写端、slirp4netns
|
|
267
|
+
// 持读端),所以必须把父进程侧的 fd 重新 dup 给 slirp4netns。
|
|
268
|
+
const exitReadFd = (holder.stdio[3] as unknown as { _handle?: { fd?: number } } | null)?._handle
|
|
269
|
+
?.fd;
|
|
270
|
+
if (typeof exitReadFd !== "number") {
|
|
271
|
+
throw new TypeError("Failed to resolve exit-fd from holder stdio");
|
|
272
|
+
}
|
|
273
|
+
slirp = spawn(
|
|
274
|
+
slirp4netnsPath,
|
|
275
|
+
[
|
|
276
|
+
"-c",
|
|
277
|
+
`--mtu=${TUN_MTU}`,
|
|
278
|
+
`--userns-path=/proc/${String(holderPid)}/ns/user`,
|
|
279
|
+
"--netns-type=pid",
|
|
280
|
+
String(holderPid),
|
|
281
|
+
"tap0",
|
|
282
|
+
// exit-fd:本子进程的 fd 3(stdio 第 4 项 dup 为 fd 3)
|
|
283
|
+
"-e",
|
|
284
|
+
"3",
|
|
285
|
+
],
|
|
286
|
+
{ stdio: ["ignore", "pipe", "pipe", exitReadFd] },
|
|
287
|
+
);
|
|
288
|
+
forwardOutput(slirp, holderOutput);
|
|
289
|
+
if (slirp.pid === undefined) {
|
|
290
|
+
throw new Error("Failed to start slirp4netns");
|
|
291
|
+
}
|
|
292
|
+
|
|
242
293
|
await waitForMihomoStarted(holder);
|
|
243
294
|
|
|
244
|
-
const state: NetworkStackState = { holderPid };
|
|
295
|
+
const state: NetworkStackState = { holderPid, slirpPid: slirp.pid };
|
|
245
296
|
const stack: NetworkStack = {
|
|
246
297
|
exec: async (execOptions: NetworkStackExecOptions) => {
|
|
247
298
|
const child = spawn(
|
|
@@ -311,10 +362,14 @@ export async function startNetworkStack(options: NetworkStackOptions): Promise<N
|
|
|
311
362
|
},
|
|
312
363
|
stop: async () => {
|
|
313
364
|
const children = await readChildPids(state.holderPid);
|
|
365
|
+
// slirp4netns 持有 tap fd(pin 住 netns),必须随 holder 一起显式终止;
|
|
366
|
+
// 先杀它再杀 holder,避免 stop() 与 exit-fd HUP 的收尾时序竞争
|
|
367
|
+
killProcess(state.slirpPid);
|
|
314
368
|
// SIGTERM unshare → --kill-child 转发 SIGTERM 给 init(pid ns 的 pid 1),
|
|
315
369
|
// init 优雅停 mihomo 后退出,内核清理 pid ns 内全部进程,ns 引用随之归零
|
|
316
370
|
killProcess(state.holderPid);
|
|
317
371
|
await waitForExit(state.holderPid);
|
|
372
|
+
await waitForExit(state.slirpPid);
|
|
318
373
|
// 兜底:init 未在超时内退出 → SIGKILL init → 内核清 pid ns
|
|
319
374
|
for (const pid of children) {
|
|
320
375
|
killProcess(pid, "SIGKILL");
|
|
@@ -326,7 +381,11 @@ export async function startNetworkStack(options: NetworkStackOptions): Promise<N
|
|
|
326
381
|
return stack;
|
|
327
382
|
} catch (error) {
|
|
328
383
|
// 失败清理:holder(unshare)的 SIGTERM 经 --kill-child 转发给 init,
|
|
329
|
-
// init 退出时内核清理 pid ns
|
|
384
|
+
// init 退出时内核清理 pid ns 内全部进程;slirp4netns 在宿主侧,需单独终止
|
|
385
|
+
//(exit-fd 写端也会随 holder 死亡关闭,这里主动杀只是不等到 HUP 轮询)
|
|
386
|
+
if (slirp?.pid) {
|
|
387
|
+
killProcess(slirp.pid);
|
|
388
|
+
}
|
|
330
389
|
if (holder?.pid) {
|
|
331
390
|
const children = await readChildPids(holder.pid);
|
|
332
391
|
killProcess(holder.pid);
|
package/src/lib/lsp/client.ts
CHANGED
|
@@ -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
|
|
119
|
-
|
|
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
|
|
251
|
-
const watcherRegistrations = new Map<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) =>
|
|
311
|
-
|
|
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
|
-
|
|
758
|
-
return [
|
|
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;
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* 诊断报告格式化:报告 ERROR 与 WARN,每个文件最多
|
|
2
|
+
* 诊断报告格式化:报告 ERROR 与 WARN,每个文件最多 5 条。
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import type { Diagnostic } from "./client.js";
|
|
6
6
|
|
|
7
|
-
const MAX_PER_FILE =
|
|
7
|
+
const MAX_PER_FILE = 5;
|
|
8
8
|
|
|
9
9
|
const SEVERITY_LABELS: Record<number, string> = {
|
|
10
10
|
1: "ERROR",
|
package/src/lib/lsp/lsp.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
350
|
-
if (
|
|
351
|
-
|
|
352
|
-
|
|
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 (
|