pi-web-ui 0.58.0 → 0.59.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -8
- package/README.zh-CN.md +19 -8
- package/dist/server/agent-service.js +15 -4
- package/dist/server/dsh/dsh-agent-service.js +2806 -0
- package/dist/server/dsh/dsh-client.js +518 -0
- package/dist/server/dsh/dsh-serialize.js +253 -0
- package/dist/server/dsh/dsh-sessions.js +207 -0
- package/dist/server/dsh/runtime/cordis.yml +1 -0
- package/dist/server/dsh/runtime/goal-rpc.mjs +662 -0
- package/dist/server/dsh/runtime/launcher.mjs +174 -0
- package/dist/server/dsh/runtime/override.patch.yml +71 -0
- package/dist/server/dsh/runtime/runtime-root.mjs +90 -0
- package/dist/server/files-service.js +1 -1
- package/dist/server/index.js +21 -4
- package/dist/server/terminals.js +275 -43
- package/dist/server/webui-context.js +0 -2
- package/package.json +5 -2
- package/web/dist/assets/TerminalPanel-_VfntAyG.js +2 -0
- package/web/dist/assets/index-DRsP4BO2.css +10 -0
- package/web/dist/assets/index-MeifpZzi.js +321 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/TerminalPanel-BWjl4gpk.js +0 -2
- package/web/dist/assets/index-2pBtToy6.js +0 -321
- package/web/dist/assets/index-D9m2sxDj.css +0 -10
|
@@ -0,0 +1,518 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-client.ts — stdio JSON-RPC 2.0 客户端 for the pi-web-ui DSH runtime
|
|
3
|
+
* (server/dsh/runtime/launcher.mjs).
|
|
4
|
+
*
|
|
5
|
+
* 协议(每行一个紧凑 JSON 帧,见 dsh-sdk-jsonrpc-server):
|
|
6
|
+
* client→server initialize → { serverInfo }
|
|
7
|
+
* client→server session/prompt → { messageId }(持久化入队回执)
|
|
8
|
+
* client→server shutdown → {}(运行时有序释放后 exit 0)
|
|
9
|
+
* server→client session.event (每个会话,全量持久事件)
|
|
10
|
+
* server→client session.status (running/idle 转换)
|
|
11
|
+
* server→client subagent.started / subagent.finished
|
|
12
|
+
*
|
|
13
|
+
* 官方协议面限制(dsh 0.1.1-rc.2):无 per-session close、无 prompt 取消、
|
|
14
|
+
* 无 per-prompt 结果。因此:
|
|
15
|
+
* - 中止 = kill 进程树(会话 JSONL 在磁盘,进程重建不丢)
|
|
16
|
+
* - 换模型 = 重启运行时(model 在 initialize 固定)
|
|
17
|
+
* - 会话列表/回放 = 直读 JSONL(见 dsh-serialize.ts)
|
|
18
|
+
*/
|
|
19
|
+
import { createRequire } from "node:module";
|
|
20
|
+
import { spawn } from "node:child_process";
|
|
21
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
22
|
+
import { homedir } from "node:os";
|
|
23
|
+
import { dirname, join, resolve } from "node:path";
|
|
24
|
+
import { fileURLToPath } from "node:url";
|
|
25
|
+
/** 项目依赖解析(tsc 编译后 dist/server/dsh/ 里向上找 node_modules)。 */
|
|
26
|
+
const require = createRequire(import.meta.url);
|
|
27
|
+
export class DshRpcError extends Error {
|
|
28
|
+
code;
|
|
29
|
+
data;
|
|
30
|
+
constructor(message, code, data) {
|
|
31
|
+
super(message);
|
|
32
|
+
this.name = "DshRpcError";
|
|
33
|
+
this.code = code;
|
|
34
|
+
this.data = data;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
export class DshTransportError extends Error {
|
|
38
|
+
constructor(message) {
|
|
39
|
+
super(message);
|
|
40
|
+
this.name = "DshTransportError";
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/** 读取 DeepSeek API key:<agentDir>/auth.json 的 deepseek.key(ds-web-ui 同款)。
|
|
44
|
+
* agentDir 缺省为 ~/.pi/agent(尊重 PI_CODING_AGENT_DIR 由调用方传入)。 */
|
|
45
|
+
export function loadDeepSeekKey(agentDir) {
|
|
46
|
+
try {
|
|
47
|
+
const auth = JSON.parse(readFileSync(join(agentDir ?? join(homedir(), ".pi", "agent"), "auth.json"), "utf8"));
|
|
48
|
+
const ds = auth.deepseek;
|
|
49
|
+
const key = ds?.key ?? auth.deepseek;
|
|
50
|
+
return typeof key === "string" && key ? key : undefined;
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* 一个 DSH 运行时子进程。start() 惰性 spawn + initialize 握手;
|
|
58
|
+
* prompt() 按需隐式建会话;kill() 强杀进程树(中止);restart() 换模型。
|
|
59
|
+
*/
|
|
60
|
+
export class DshRuntime {
|
|
61
|
+
cwd;
|
|
62
|
+
provider;
|
|
63
|
+
model;
|
|
64
|
+
maxTokens;
|
|
65
|
+
sessionRoot;
|
|
66
|
+
dataDir;
|
|
67
|
+
agentDir;
|
|
68
|
+
launcher;
|
|
69
|
+
/** 额外环境变量(可运行时修改:DSH_PERSONA 等由 launcher env 注入)。 */
|
|
70
|
+
env;
|
|
71
|
+
jsonrpcEntry;
|
|
72
|
+
proc = null;
|
|
73
|
+
startPromise = null;
|
|
74
|
+
buffer = "";
|
|
75
|
+
nextId = 1;
|
|
76
|
+
pending = new Map();
|
|
77
|
+
notificationHandler = null;
|
|
78
|
+
stderrTail = "";
|
|
79
|
+
closed = false;
|
|
80
|
+
initialized = false;
|
|
81
|
+
/** PI_WEB_DSH_DEBUG=1 时把 RPC 帧/生命周期事件打到 stderr(诊断用,默认关)。 */
|
|
82
|
+
debugEnabled = process.env.PI_WEB_DSH_DEBUG === "1";
|
|
83
|
+
debug(...args) {
|
|
84
|
+
if (this.debugEnabled)
|
|
85
|
+
console.error("[dsh:client]", ...args);
|
|
86
|
+
}
|
|
87
|
+
/** 进程退出回调(kill/abort 后用于重 spawn 前清理)。
|
|
88
|
+
* intentional = 由 kill()/close() 主动触发(反之 = 意外崩溃,供 watchdog 判断)。 */
|
|
89
|
+
onExit = null;
|
|
90
|
+
/** 每次成功 initialize 后触发(含初次启动 / 换模型重启 / watchdog 重启)。
|
|
91
|
+
* 宿主用它重新注册一次性资源(如插件工具桥),因为重 spawn 后 ctx 是全新的。 */
|
|
92
|
+
onStarted = null;
|
|
93
|
+
constructor(opts) {
|
|
94
|
+
this.cwd = resolve(opts.cwd);
|
|
95
|
+
this.provider = opts.provider ?? "deepseek-official";
|
|
96
|
+
this.model = opts.model ?? "deepseek-v4-flash";
|
|
97
|
+
this.maxTokens = opts.maxTokens;
|
|
98
|
+
this.sessionRoot = opts.sessionRoot;
|
|
99
|
+
this.dataDir = opts.dataDir;
|
|
100
|
+
this.agentDir = opts.agentDir;
|
|
101
|
+
this.launcher =
|
|
102
|
+
opts.launcher ??
|
|
103
|
+
join(dirname(fileURLToPath(import.meta.url)), "runtime", "launcher.mjs");
|
|
104
|
+
this.jsonrpcEntry =
|
|
105
|
+
opts.jsonrpcEntry ??
|
|
106
|
+
(() => {
|
|
107
|
+
try {
|
|
108
|
+
return require.resolve("@deepseek-ai/dsh-sdk-jsonrpc-server");
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
// 回退:相对源码/编译目录向上找项目 node_modules。
|
|
112
|
+
return join(resolve(dirname(fileURLToPath(import.meta.url)), "..", ".."), "node_modules", "@deepseek-ai", "dsh-sdk-jsonrpc-server", "lib", "index.js");
|
|
113
|
+
}
|
|
114
|
+
})();
|
|
115
|
+
this.env = opts.env ?? {};
|
|
116
|
+
}
|
|
117
|
+
/** 运行时子进程是否存活。 */
|
|
118
|
+
get alive() {
|
|
119
|
+
return !!this.proc && this.proc.exitCode === null;
|
|
120
|
+
}
|
|
121
|
+
get running() {
|
|
122
|
+
return this.alive;
|
|
123
|
+
}
|
|
124
|
+
/** 启动子进程 + initialize 握手(幂等;并发调用共享同一个启动任务)。 */
|
|
125
|
+
start() {
|
|
126
|
+
if (this.alive && this.initialized)
|
|
127
|
+
return Promise.resolve();
|
|
128
|
+
if (!this.startPromise) {
|
|
129
|
+
this.startPromise = this.doStart().finally(() => {
|
|
130
|
+
this.startPromise = null;
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
return this.startPromise;
|
|
134
|
+
}
|
|
135
|
+
async doStart() {
|
|
136
|
+
if (!existsSync(this.launcher)) {
|
|
137
|
+
throw new DshTransportError(`launcher 不存在: ${this.launcher}`);
|
|
138
|
+
}
|
|
139
|
+
if (!existsSync(this.jsonrpcEntry)) {
|
|
140
|
+
throw new DshTransportError(`dsh-sdk-jsonrpc-server 未安装(缺 ${this.jsonrpcEntry})。请先 npm i @deepseek-ai/dsh-sdk-jsonrpc-server@0.1.1-rc.2`);
|
|
141
|
+
}
|
|
142
|
+
const key = loadDeepSeekKey(this.agentDir);
|
|
143
|
+
const env = {
|
|
144
|
+
...process.env,
|
|
145
|
+
DSH_CWD: this.cwd,
|
|
146
|
+
...this.env,
|
|
147
|
+
};
|
|
148
|
+
if (this.sessionRoot)
|
|
149
|
+
env.DSH_SESSION_ROOT = this.sessionRoot;
|
|
150
|
+
if (this.dataDir)
|
|
151
|
+
env.PI_WEB_DSH_DATA_DIR = this.dataDir;
|
|
152
|
+
if (key && !this.env.DEEPSEEK_API_KEY)
|
|
153
|
+
env.DEEPSEEK_API_KEY = key;
|
|
154
|
+
env.PI_WEB_DSH_JSONRPC_ENTRY = this.jsonrpcEntry;
|
|
155
|
+
this.closed = false;
|
|
156
|
+
this.buffer = "";
|
|
157
|
+
this.pending.clear();
|
|
158
|
+
this.stderrTail = "";
|
|
159
|
+
this.proc = spawn(process.execPath, [this.launcher], {
|
|
160
|
+
env,
|
|
161
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
162
|
+
windowsHide: true,
|
|
163
|
+
// POSIX:独立进程组,硬中断时 SIGKILL(-pid) 一次带走运行时 + 它的
|
|
164
|
+
// bash/pwsh 子进程。
|
|
165
|
+
...(process.platform !== "win32" ? { detached: true } : {}),
|
|
166
|
+
});
|
|
167
|
+
const spawned = this.proc;
|
|
168
|
+
spawned.stdout.setEncoding("utf8");
|
|
169
|
+
spawned.stdout.on("data", (chunk) => this._onData(chunk));
|
|
170
|
+
spawned.stderr.setEncoding("utf8");
|
|
171
|
+
spawned.stderr.on("data", (chunk) => {
|
|
172
|
+
this.stderrTail = (this.stderrTail + chunk).slice(-4000);
|
|
173
|
+
});
|
|
174
|
+
spawned.on("error", (err) => {
|
|
175
|
+
this.failPending(new DshTransportError(`runtime 启动失败: ${err.message}`));
|
|
176
|
+
});
|
|
177
|
+
spawned.on("exit", (code, signal) => {
|
|
178
|
+
// 只处理当前 proc 的退出:kill/restart 后旧 proc 迟到的 exit 事件
|
|
179
|
+
// 不得 failPending(否则误伤新 initialize)也不得触发 watchdog。
|
|
180
|
+
if (this.proc !== spawned)
|
|
181
|
+
return;
|
|
182
|
+
const intentional = this.closed;
|
|
183
|
+
this.debug("exit", { code, signal, intentional });
|
|
184
|
+
const err = new DshTransportError(`DSH runtime 已退出 (code=${code} signal=${signal}) stderr: ${this.stderrTail.slice(-400)}`);
|
|
185
|
+
this.failPending(err);
|
|
186
|
+
this.initialized = false;
|
|
187
|
+
this.onExit?.(code, signal, intentional);
|
|
188
|
+
});
|
|
189
|
+
try {
|
|
190
|
+
await this._request("initialize", {
|
|
191
|
+
cwd: this.cwd,
|
|
192
|
+
provider: this.provider,
|
|
193
|
+
model: this.model,
|
|
194
|
+
...(this.maxTokens ? { maxTokens: this.maxTokens } : {}),
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
catch (err) {
|
|
198
|
+
console.error(`[dsh] initialize 失败 (model=${this.model} cwd=${this.cwd}): ${err.message}` +
|
|
199
|
+
(this.stderrTail ? `\n launcher stderr: ${this.stderrTail.slice(-600)}` : ""));
|
|
200
|
+
// initialize 失败:确认子进程已被清理,避免半死进程占着 stdin。
|
|
201
|
+
void this.kill();
|
|
202
|
+
throw err;
|
|
203
|
+
}
|
|
204
|
+
this.initialized = true;
|
|
205
|
+
this.onStarted?.();
|
|
206
|
+
}
|
|
207
|
+
failPending(err) {
|
|
208
|
+
for (const p of this.pending.values())
|
|
209
|
+
p.reject(err);
|
|
210
|
+
this.pending.clear();
|
|
211
|
+
}
|
|
212
|
+
_onData(chunk) {
|
|
213
|
+
this.buffer += chunk;
|
|
214
|
+
let idx;
|
|
215
|
+
while ((idx = this.buffer.indexOf("\n")) !== -1) {
|
|
216
|
+
const line = this.buffer.slice(0, idx).trim();
|
|
217
|
+
this.buffer = this.buffer.slice(idx + 1);
|
|
218
|
+
if (!line)
|
|
219
|
+
continue;
|
|
220
|
+
let msg;
|
|
221
|
+
try {
|
|
222
|
+
msg = JSON.parse(line);
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
continue; // 协议:畸形帧忽略
|
|
226
|
+
}
|
|
227
|
+
if (msg.id !== undefined && msg.id !== null) {
|
|
228
|
+
const p = this.pending.get(msg.id);
|
|
229
|
+
if (!p)
|
|
230
|
+
continue;
|
|
231
|
+
this.pending.delete(msg.id);
|
|
232
|
+
if (msg.error) {
|
|
233
|
+
p.reject(new DshRpcError(msg.error.message ?? "rpc error", msg.error.code, msg.error.data));
|
|
234
|
+
}
|
|
235
|
+
else {
|
|
236
|
+
p.resolve(msg.result ?? {});
|
|
237
|
+
}
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
if (msg.method && this.notificationHandler) {
|
|
241
|
+
this.debug("<-", msg.method);
|
|
242
|
+
this.notificationHandler(msg.method, msg.params ?? {});
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
_request(method, params, timeoutMs = 120_000) {
|
|
247
|
+
const id = this.nextId++;
|
|
248
|
+
this.debug("->", method, JSON.stringify(params)?.slice(0, 200));
|
|
249
|
+
return new Promise((resolve2, reject) => {
|
|
250
|
+
const timer = setTimeout(() => {
|
|
251
|
+
this.pending.delete(id);
|
|
252
|
+
reject(new DshTransportError(`请求 ${method} 超时`));
|
|
253
|
+
}, timeoutMs);
|
|
254
|
+
this.pending.set(id, {
|
|
255
|
+
resolve: (v) => {
|
|
256
|
+
clearTimeout(timer);
|
|
257
|
+
resolve2(v);
|
|
258
|
+
},
|
|
259
|
+
reject: (e) => {
|
|
260
|
+
clearTimeout(timer);
|
|
261
|
+
reject(e);
|
|
262
|
+
},
|
|
263
|
+
});
|
|
264
|
+
this._write({ jsonrpc: "2.0", id, method, params });
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
_write(msg) {
|
|
268
|
+
const proc = this.proc;
|
|
269
|
+
if (!proc || !proc.stdin || proc.stdin.destroyed) {
|
|
270
|
+
throw new DshTransportError("runtime 未启动");
|
|
271
|
+
}
|
|
272
|
+
proc.stdin.write(JSON.stringify(msg) + "\n");
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* 向一个会话排队 prompt。返回持久化收据 messageId(运行时接受即 resolve)。
|
|
276
|
+
* 会话不存在时按需隐式创建。
|
|
277
|
+
*/
|
|
278
|
+
async prompt(sessionId, contentBlocks) {
|
|
279
|
+
await this.start();
|
|
280
|
+
const res = (await this._request("session/prompt", {
|
|
281
|
+
sessionId,
|
|
282
|
+
contentBlocks,
|
|
283
|
+
}));
|
|
284
|
+
if (typeof res.messageId !== "string") {
|
|
285
|
+
throw new DshTransportError("session/prompt 未返回 messageId");
|
|
286
|
+
}
|
|
287
|
+
return res.messageId;
|
|
288
|
+
}
|
|
289
|
+
/** 设置通知处理器(session.event / session.status / subagent.*)。 */
|
|
290
|
+
onNotification(handler) {
|
|
291
|
+
this.notificationHandler = handler;
|
|
292
|
+
}
|
|
293
|
+
// -----------------------------------------------------------------------
|
|
294
|
+
// goal RPC(goal-rpc.mjs wrapper 插件直连 DSH 原生 goal 域)
|
|
295
|
+
// -----------------------------------------------------------------------
|
|
296
|
+
/** 创建(或替换已完成的)目标并 arm;round-driver 自动续轮。返回原生 view。 */
|
|
297
|
+
async goalSet(sessionId, objective, maxGoalRounds) {
|
|
298
|
+
await this.start();
|
|
299
|
+
return this._request("goal/set", {
|
|
300
|
+
sessionId,
|
|
301
|
+
objective,
|
|
302
|
+
...(maxGoalRounds && maxGoalRounds > 0 ? { maxGoalRounds } : {}),
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
/** 当前目标视图({goal|null, activation})。 */
|
|
306
|
+
async goalGet(sessionId) {
|
|
307
|
+
await this.start();
|
|
308
|
+
return this._request("goal/get", { sessionId });
|
|
309
|
+
}
|
|
310
|
+
/** 清除当前目标(保留 durable 墓碑与历史)。 */
|
|
311
|
+
async goalClear(sessionId) {
|
|
312
|
+
await this.start();
|
|
313
|
+
return this._request("goal/clear", { sessionId });
|
|
314
|
+
}
|
|
315
|
+
/** 恢复被 disarm 的目标(abort 重启运行时后轮次驱动停止)。 */
|
|
316
|
+
async goalResume(sessionId) {
|
|
317
|
+
await this.start();
|
|
318
|
+
return this._request("goal/resume", { sessionId });
|
|
319
|
+
}
|
|
320
|
+
/** P2-17 查询运行时模型目录(adapter 真实清单,含 inputModalities)。 */
|
|
321
|
+
async listModels(provider = "deepseek-official") {
|
|
322
|
+
await this.start();
|
|
323
|
+
return this._request("model/list", { provider });
|
|
324
|
+
}
|
|
325
|
+
// -----------------------------------------------------------------------
|
|
326
|
+
// 附件 RPC(视觉桥):base64 图片 ↔ durable ImageAttachmentRef
|
|
327
|
+
// -----------------------------------------------------------------------
|
|
328
|
+
/** 保存 base64 图片 → ImageAttachmentRef(供 prompt 的 image 块引用)。 */
|
|
329
|
+
async attachmentSave(mediaType, data, name) {
|
|
330
|
+
await this.start();
|
|
331
|
+
return this._request("attachment/save", {
|
|
332
|
+
mediaType,
|
|
333
|
+
data,
|
|
334
|
+
...(name ? { name } : {}),
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
/** 按 ref 回读图片字节(base64),回放时补全前端显示。 */
|
|
338
|
+
async attachmentRead(ref) {
|
|
339
|
+
await this.start();
|
|
340
|
+
return this._request("attachment/read", { ref });
|
|
341
|
+
}
|
|
342
|
+
/** 回答模型提问(question.pending 通知的 id)。cancelled = 用户取消。 */
|
|
343
|
+
async answerQuestion(id, answers, cancelled) {
|
|
344
|
+
await this.start();
|
|
345
|
+
return this._request("question/answer", { id, answers, ...(cancelled ? { cancelled: true } : {}) });
|
|
346
|
+
}
|
|
347
|
+
// -----------------------------------------------------------------------
|
|
348
|
+
// 工具桥 RPC(#15 插件注入点):sync 注册插件工具 / list 校验 / call-result 回传
|
|
349
|
+
// -----------------------------------------------------------------------
|
|
350
|
+
/** 把一批插件工具(name/description/parameters)注册为 DSH 原生工具。返回注册清单。 */
|
|
351
|
+
async syncTools(tools) {
|
|
352
|
+
await this.start();
|
|
353
|
+
return this._request("tools/sync", { tools });
|
|
354
|
+
}
|
|
355
|
+
/** 列出运行时当前可见的工具 schema(零 key 校验/调试用)。 */
|
|
356
|
+
async listTools() {
|
|
357
|
+
await this.start();
|
|
358
|
+
return this._request("tools/list", {});
|
|
359
|
+
}
|
|
360
|
+
/** 回传一个桥接工具的执行结果(isError=true 时 result 为错误信息)。 */
|
|
361
|
+
async toolsCallResult(id, result, isError) {
|
|
362
|
+
await this.start();
|
|
363
|
+
return this._request("tools/call-result", { id, result, ...(isError ? { isError: true } : {}) });
|
|
364
|
+
}
|
|
365
|
+
/** 调试/probe 用:触发一个桥接工具的完整往返(需运行时 PI_WEB_DSH_DEBUG=1)。 */
|
|
366
|
+
async invokeTool(name, args) {
|
|
367
|
+
await this.start();
|
|
368
|
+
return this._request("tools/invoke", { name, args });
|
|
369
|
+
}
|
|
370
|
+
// -----------------------------------------------------------------------
|
|
371
|
+
// 技能 RPC(#18 技能启停 UI):skills/list + skills/set-disabled
|
|
372
|
+
// -----------------------------------------------------------------------
|
|
373
|
+
/** 列出运行时当前可见技能(SkillRegistry.list)。 */
|
|
374
|
+
async listSkills() {
|
|
375
|
+
await this.start();
|
|
376
|
+
return this._request("skills/list", {});
|
|
377
|
+
}
|
|
378
|
+
/** 设置禁用技能集合(供晚 pre-step 钩子过滤 skill-catalog 消息)。 */
|
|
379
|
+
async setDisabledSkills(skills) {
|
|
380
|
+
await this.start();
|
|
381
|
+
return this._request("skills/set-disabled", { skills });
|
|
382
|
+
}
|
|
383
|
+
/** 优雅关闭:shutdown 握手 → stdin EOF → SIGTERM → SIGKILL 阶梯。 */
|
|
384
|
+
async close() {
|
|
385
|
+
if (this.closed)
|
|
386
|
+
return;
|
|
387
|
+
this.closed = true;
|
|
388
|
+
const proc = this.proc;
|
|
389
|
+
if (!proc || proc.exitCode !== null) {
|
|
390
|
+
this.proc = null;
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
try {
|
|
394
|
+
await this._request("shutdown", {}, 2000);
|
|
395
|
+
}
|
|
396
|
+
catch {
|
|
397
|
+
/* fall through to kill ladder */
|
|
398
|
+
}
|
|
399
|
+
try {
|
|
400
|
+
proc.stdin.end();
|
|
401
|
+
}
|
|
402
|
+
catch {
|
|
403
|
+
/* ignore */
|
|
404
|
+
}
|
|
405
|
+
await new Promise((r) => {
|
|
406
|
+
const t = setTimeout(r, 1500);
|
|
407
|
+
proc.once("exit", () => {
|
|
408
|
+
clearTimeout(t);
|
|
409
|
+
r();
|
|
410
|
+
});
|
|
411
|
+
});
|
|
412
|
+
if (proc.exitCode === null) {
|
|
413
|
+
try {
|
|
414
|
+
proc.kill("SIGTERM");
|
|
415
|
+
}
|
|
416
|
+
catch {
|
|
417
|
+
/* ignore */
|
|
418
|
+
}
|
|
419
|
+
await this._waitExit(proc, 1500);
|
|
420
|
+
}
|
|
421
|
+
if (proc.exitCode === null) {
|
|
422
|
+
try {
|
|
423
|
+
proc.kill("SIGKILL");
|
|
424
|
+
}
|
|
425
|
+
catch {
|
|
426
|
+
/* ignore */
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
this.proc = null;
|
|
430
|
+
}
|
|
431
|
+
_waitExit(proc, ms) {
|
|
432
|
+
return new Promise((r) => {
|
|
433
|
+
const t = setTimeout(r, ms);
|
|
434
|
+
proc.once("exit", () => {
|
|
435
|
+
clearTimeout(t);
|
|
436
|
+
r();
|
|
437
|
+
});
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
/**
|
|
441
|
+
* 强杀运行时子进程 + 整棵进程树,无 shutdown 握手(硬中止)。
|
|
442
|
+
* win32: taskkill /pid X /T /F;posix: SIGKILL(-pid)(detached 进程组)。
|
|
443
|
+
*/
|
|
444
|
+
async kill() {
|
|
445
|
+
const proc = this.proc;
|
|
446
|
+
this.debug("kill", { pid: proc?.pid });
|
|
447
|
+
this.closed = true;
|
|
448
|
+
if (!proc || proc.exitCode !== null) {
|
|
449
|
+
this.proc = null;
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
const pid = proc.pid;
|
|
453
|
+
if (pid === undefined) {
|
|
454
|
+
this.proc = null;
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
this.proc = null;
|
|
458
|
+
this.failPending(new DshTransportError("runtime killed (interrupt)"));
|
|
459
|
+
try {
|
|
460
|
+
if (process.platform === "win32") {
|
|
461
|
+
const killer = spawn("taskkill", ["/pid", String(pid), "/T", "/F"], {
|
|
462
|
+
stdio: "ignore",
|
|
463
|
+
windowsHide: true,
|
|
464
|
+
});
|
|
465
|
+
killer.on("error", () => {
|
|
466
|
+
try {
|
|
467
|
+
proc.kill("SIGKILL");
|
|
468
|
+
}
|
|
469
|
+
catch {
|
|
470
|
+
/* already dead */
|
|
471
|
+
}
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
else {
|
|
475
|
+
try {
|
|
476
|
+
process.kill(-pid, "SIGKILL");
|
|
477
|
+
}
|
|
478
|
+
catch {
|
|
479
|
+
try {
|
|
480
|
+
proc.kill("SIGKILL");
|
|
481
|
+
}
|
|
482
|
+
catch {
|
|
483
|
+
/* already dead */
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
catch {
|
|
489
|
+
try {
|
|
490
|
+
proc.kill("SIGKILL");
|
|
491
|
+
}
|
|
492
|
+
catch {
|
|
493
|
+
/* already dead */
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
/** 换模型:关掉当前运行时(若活着),下次 start() 用新 model 重新 spawn。 */
|
|
498
|
+
async restart(newModel, newProvider) {
|
|
499
|
+
const wasAlive = this.alive;
|
|
500
|
+
this.debug("restart", { newModel, newProvider, wasAlive });
|
|
501
|
+
if (wasAlive) {
|
|
502
|
+
await this.kill();
|
|
503
|
+
// 等旧进程真正退出(避免 pid 复用竞态)。
|
|
504
|
+
await new Promise((r) => setTimeout(r, 150));
|
|
505
|
+
}
|
|
506
|
+
if (newModel)
|
|
507
|
+
this.model = newModel;
|
|
508
|
+
if (newProvider)
|
|
509
|
+
this.provider = newProvider;
|
|
510
|
+
this.initialized = false;
|
|
511
|
+
if (wasAlive)
|
|
512
|
+
await this.start();
|
|
513
|
+
}
|
|
514
|
+
/** stderr 尾部(诊断用)。 */
|
|
515
|
+
get stderr() {
|
|
516
|
+
return this.stderrTail;
|
|
517
|
+
}
|
|
518
|
+
}
|