pi-web-ui 0.57.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.
@@ -0,0 +1,2806 @@
1
+ /**
2
+ * dsh-agent-service.ts — DeepSeek Harness (dsh) 引擎的 AgentService 等价物。
3
+ *
4
+ * 每个浏览器客户端(clientId)持有:
5
+ * - 一个 DshRuntime 子进程(stdio JSON-RPC,模型在 initialize 固定 → 换模型 = 重启)
6
+ * - 一个或多个 conversation(1:1 映射 DSH session id)
7
+ * - 每 conversation 一个 TerminalManager
8
+ *
9
+ * 消息折叠(事件面 ground truth,见 docs/dsh-engine.md §2.1):
10
+ * session.event 持久事件(user/message、assistant/message、tool/result、
11
+ * assistant/chunk、turn/step、session/title …)→ 追加到 conversation 的
12
+ * UiMessage[](回放 JSONL 初始化 + 增量追加,按消息 id 去重)。
13
+ * assistant/chunk 增量累积 streamingMessage(reasoning→thinking、
14
+ * text→text、tool-call→toolCall)。
15
+ * session.status(running/idle)驱动 isStreaming。
16
+ *
17
+ * 协议面限制的处理:
18
+ * - 中止 = kill 运行时进程树(JSONL 在磁盘,重建不丢)→ 自动重启保持可用
19
+ * - 换模型 / 换项目 = 重启运行时(initialize 固定 model + cwd)
20
+ * - 会话列表/回放 = 直读 JSONL(dsh-sessions.ts)
21
+ * - queue 语义:DSH 无 mid-run steering —— isStreaming 时 prompt 走 followUp
22
+ * (运行时 inbox 排队,当前 run 结束后消费)
23
+ *
24
+ * 引擎无关模块复用:FilesService(文件树/预览)、scm.ts(git 查询)、
25
+ * BgServerTracker(后台任务)、TerminalManager(PTY)、uploads.ts。
26
+ */
27
+ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
28
+ import { basename, dirname, join, resolve, sep } from "node:path";
29
+ import { randomUUID } from "node:crypto";
30
+ import { homedir } from "node:os";
31
+ import { BgServerTracker } from "../bg-servers.js";
32
+ import { ClientStateStore } from "../client-state.js";
33
+ import { FilesService, workspacePath } from "../files-service.js";
34
+ import { QuiesceRejectedError } from "../agent-service.js";
35
+ import { killPidTree } from "../process-utils.js";
36
+ import { NATIVE_COMMANDS, parseSlash } from "../slash-commands.js";
37
+ import { TerminalManager, loadCommands, saveCommandsFile } from "../terminals.js";
38
+ import { saveUpload, uploadsRoot } from "../uploads.js";
39
+ import { checkAll as checkAllUpdates, collectTargets, compareVersions as compareSemver } from "../update-check.js";
40
+ import { previewKind } from "../text-sniff.js";
41
+ import { DshRuntime, DshTransportError, loadDeepSeekKey } from "./dsh-client.js";
42
+ import { DshStreamAccumulator, assistantMessageEventToUiMessage, toolResultEventToUiMessage, userMessageEventToUiMessage, } from "./dsh-serialize.js";
43
+ import { firstUserText, findSessionFilesForCwd, projectKey, readSessionLog, replayEventsToMessages, } from "./dsh-sessions.js";
44
+ const SNAPSHOT_INTERVAL_MS = 60;
45
+ const MAX_OPEN_CONVERSATIONS = 8;
46
+ const DEFAULT_CONV_TITLE = "新对话";
47
+ const DEFAULT_MODEL = "deepseek-v4-flash";
48
+ /** DSH 可选模型(顶栏模型选择器)。仅 deepseek-v4-flash-vision-exp 支持图片
49
+ * (adapter 默认目录 inputModalities: [text, image]);flash/pro 是 text-only。 */
50
+ const DSH_MODELS = [
51
+ { id: "deepseek-v4-flash", name: "DeepSeek V4 Flash", provider: "deepseek", vision: false },
52
+ { id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", provider: "deepseek", vision: false },
53
+ {
54
+ id: "deepseek-v4-flash-vision-exp",
55
+ name: "DeepSeek V4 Flash Vision (exp)",
56
+ provider: "deepseek",
57
+ vision: true,
58
+ },
59
+ ];
60
+ /** DeepSeek V4 context window + 官方每 1M token 定价(USD,api-docs.deepseek.com)。 */
61
+ const DSH_CONTEXT_WINDOW = 1_000_000;
62
+ const DSH_PRICE_INPUT = 0.14;
63
+ const DSH_PRICE_CACHE_READ = 0.0028;
64
+ const DSH_PRICE_OUTPUT = 0.28;
65
+ /** 会话 root:<dataDir>/dsh-sessions(与 pi 引擎的会话目录隔离)。 */
66
+ export function dshSessionRoot(dataDir) {
67
+ return join(dataDir, "dsh-sessions");
68
+ }
69
+ function estimateCost(t) {
70
+ if ((t.input + t.output) === 0)
71
+ return 0;
72
+ return (((t.input + (t.cacheWrite ?? 0)) * DSH_PRICE_INPUT +
73
+ (t.cacheRead ?? 0) * DSH_PRICE_CACHE_READ +
74
+ t.output * DSH_PRICE_OUTPUT) /
75
+ 1e6);
76
+ }
77
+ /** 把插件工具 execute 的原始返回值归一化成模型可读文本。
78
+ * 兼容:`{content:[{type:"text",text}...],details}` → text 拼接;string → 原串;
79
+ * 其它对象 → JSON.stringify;null/undefined → 空串。 */
80
+ export function normalizeToolResult(result) {
81
+ if (typeof result === "string")
82
+ return result;
83
+ if (result === null || result === undefined)
84
+ return "";
85
+ if (typeof result === "object") {
86
+ const content = result.content;
87
+ if (Array.isArray(content)) {
88
+ const texts = content
89
+ .filter((b) => !!b && typeof b === "object" && b.type === "text")
90
+ .map((b) => String(b.text ?? ""));
91
+ if (texts.length)
92
+ return texts.join("\n");
93
+ }
94
+ try {
95
+ return JSON.stringify(result);
96
+ }
97
+ catch {
98
+ return String(result);
99
+ }
100
+ }
101
+ return String(result);
102
+ }
103
+ const DEFAULT_SETTINGS = {
104
+ promptMode: "append",
105
+ customSystemPrompt: "",
106
+ disabledSkills: [],
107
+ disabledExtensions: [],
108
+ terminalToolsEnabled: true,
109
+ terminalBash: false,
110
+ terminalBashIdleMs: 15_000,
111
+ thinkingWrap: false,
112
+ toolsWrap: true,
113
+ disabledPlugins: [],
114
+ reviewPrompt: "",
115
+ };
116
+ // ---------------------------------------------------------------------------
117
+ // DshClientSession — 一个浏览器客户端
118
+ // ---------------------------------------------------------------------------
119
+ export class DshClientSession {
120
+ clientId;
121
+ cwd;
122
+ stateStore;
123
+ sessionRoot;
124
+ dataDir;
125
+ /** pi 配置目录(auth.json 所在;尊重 PI_CODING_AGENT_DIR)。 */
126
+ agentDir;
127
+ runtime;
128
+ convs = new Map();
129
+ activeId = "";
130
+ convSeq = 0;
131
+ /** 客户端级目标/审查偏好(跨会话共享的默认值,per-conversation goal 用它初始化)。 */
132
+ goalPrefs = { reviewModel: null, maxRounds: 2, locked: false };
133
+ sinks = new Set();
134
+ pendingNotices = [];
135
+ snapshotTimer = null;
136
+ snapRev = 0;
137
+ version = 0;
138
+ emittedMessages = null;
139
+ emittedRev = 0;
140
+ disposed = false;
141
+ model = DEFAULT_MODEL;
142
+ thinkingLevel = "high";
143
+ /** P0-1 watchdog:60s 窗口内最多自动重启 2 次,超限升级为报错 notice。 */
144
+ static RUNTIME_RESTART_WINDOW_MS = 60_000;
145
+ static RUNTIME_MAX_RESTARTS = 2;
146
+ runtimeRestart = { count: 0, windowStart: 0 };
147
+ /** P0-3 convs 内存回收:非活跃且非 streaming 的 conversation 定期回收(JSONL 在磁盘,回放可恢复)。 */
148
+ static RECLAIM_INTERVAL_MS = 5 * 60_000;
149
+ /** 未进左栏“运行的对话”的空闲上限。 */
150
+ static CONV_RECLAIM_IDLE_MS = 30 * 60_000;
151
+ /** 已在左栏“运行的对话”里的空闲上限(用户可见,给更长的保留期)。 */
152
+ static CONV_RECLAIM_LISTED_IDLE_MS = 24 * 3600_000;
153
+ reclaimTimer = null;
154
+ /** P1-13 会话 JSONL 保留期(PI_WEB_DSH_SESSION_RETENTION_DAYS,默认 90 天)。 */
155
+ static SESSION_RETENTION_MS = (Number(process.env.PI_WEB_DSH_SESSION_RETENTION_DAYS) || 90) * 24 * 3600_000;
156
+ retentionTimer = null;
157
+ retentionOnce = null;
158
+ settings = { ...DEFAULT_SETTINGS };
159
+ /** 最近一次从运行时拉取的技能清单(UiSkillInfo,含 enabled 由 disabledSkills 推导)。 */
160
+ skillsCache = [];
161
+ files;
162
+ bg;
163
+ /** 插件扩展点(index.ts 注入)。 */
164
+ onToolEvent;
165
+ pluginToolsProvider;
166
+ pluginCommandsProvider;
167
+ pluginBgTasksProvider;
168
+ pluginStopBgTask;
169
+ onQuit;
170
+ isQuiesced;
171
+ onCwdChanged;
172
+ constructor(clientId, cwd, stateStore, dataDir, agentDir) {
173
+ this.clientId = clientId;
174
+ this.cwd = cwd;
175
+ this.stateStore = stateStore;
176
+ this.dataDir = dataDir;
177
+ this.agentDir = agentDir;
178
+ this.sessionRoot = dshSessionRoot(dataDir);
179
+ try {
180
+ mkdirSync(this.sessionRoot, { recursive: true });
181
+ }
182
+ catch {
183
+ /* best effort */
184
+ }
185
+ this.files = new FilesService({
186
+ emit: (msg) => this.emit(msg),
187
+ isDisposed: () => this.disposed,
188
+ getCwd: () => this.cwd,
189
+ getActiveCwd: () => this.cwd,
190
+ });
191
+ this.bg = new BgServerTracker({
192
+ emit: (msg) => this.emit(msg),
193
+ flushSnapshot: () => this.flushSnapshot(),
194
+ isDisposed: () => this.disposed,
195
+ pluginTasks: () => this.pluginBgTasksProvider?.() ?? [],
196
+ });
197
+ }
198
+ static create(clientId, cwd, stateStore, dataDir, agentDir) {
199
+ const cs = new DshClientSession(clientId, cwd, stateStore, dataDir, agentDir ?? join(homedir(), ".pi", "agent"));
200
+ // 恢复上次使用的目标/审查偏好(全局记忆,跨重载存活;per-conversation
201
+ // 目标用它初始化)。
202
+ const gPrefs = stateStore.getGoalPrefs(clientId);
203
+ if (gPrefs) {
204
+ cs.goalPrefs = {
205
+ reviewModel: gPrefs.reviewModel,
206
+ maxRounds: gPrefs.maxRounds,
207
+ locked: gPrefs.locked,
208
+ };
209
+ }
210
+ // 恢复上次使用的设置(跨重连存活;DSH 忽略无行为字段但回显保持 UI 一致)。
211
+ const savedSettings = stateStore.getSettings(clientId);
212
+ if (savedSettings) {
213
+ cs.settings = {
214
+ promptMode: savedSettings.promptMode,
215
+ customSystemPrompt: savedSettings.customSystemPrompt,
216
+ disabledSkills: savedSettings.disabledSkills ?? [],
217
+ disabledExtensions: savedSettings.disabledExtensions ?? [],
218
+ terminalToolsEnabled: savedSettings.terminalToolsEnabled,
219
+ terminalBash: savedSettings.terminalBash,
220
+ terminalBashIdleMs: savedSettings.terminalBashIdleMs,
221
+ thinkingWrap: savedSettings.thinkingWrap,
222
+ toolsWrap: savedSettings.toolsWrap,
223
+ disabledPlugins: savedSettings.disabledPlugins ?? [],
224
+ reviewPrompt: savedSettings.reviewPrompt,
225
+ };
226
+ }
227
+ // 第一个 conversation = 新会话(每客户端独立 sessionId,避免多标签页/多
228
+ // 客户端共享同一 JSONL 互相串会话)。历史会话经 switch_session 恢复。
229
+ cs.makeRuntime();
230
+ const first = cs.addConversation(`web-${randomUUID().slice(0, 12)}`, cwd, false);
231
+ cs.activeId = first.id;
232
+ cs.attachRuntimeEvents();
233
+ // 每次启动成功(含初次/换模型/watchdog 重启)后重新注册插件工具桥,
234
+ // 因为重 spawn 后的 ctx.tools 是全新的,需要重新 sync 插件工具。
235
+ cs.runtime.onStarted = () => {
236
+ void cs.syncPluginTools();
237
+ void cs.pushDisabledSkillsToRuntime();
238
+ void cs.refreshSkillsFromRuntime();
239
+ };
240
+ // P0-1 watchdog:意外退出(非 kill/close 主动触发)→ 限频自动重启,保持可用。
241
+ cs.runtime.onExit = (code, signal, intentional) => {
242
+ if (intentional)
243
+ return; // kill()/close() 主动触发,不重启
244
+ cs.handleRuntimeExit(code, signal);
245
+ };
246
+ // P0-5 启动重试:1s/3s/9s 指数退避,最终失败才发 notice。
247
+ void cs.startWithRetry().catch((err) => {
248
+ console.error(`[dsh] runtime.start 失败 (client=${clientId}): ${err.message}`);
249
+ cs.pendingNotices.push({
250
+ type: "notice",
251
+ level: "error",
252
+ text: `DSH 运行时启动失败:${err.message}。请检查 DeepSeek API key(~/.pi/agent/auth.json)与 dsh 依赖安装。`,
253
+ });
254
+ });
255
+ // P0-3 convs 内存回收定时器(unref:不阻止进程退出)。
256
+ cs.reclaimTimer = setInterval(() => cs.reclaimIdleConversations(), DshClientSession.RECLAIM_INTERVAL_MS);
257
+ cs.reclaimTimer.unref?.();
258
+ // P1-13 会话 JSONL 保留期清理:启动后 10s 首清 + 每 24h 一次(幂等)。
259
+ cs.retentionOnce = setTimeout(() => void cs.cleanupExpiredSessions(), 10_000);
260
+ cs.retentionOnce.unref?.();
261
+ cs.retentionTimer = setInterval(() => void cs.cleanupExpiredSessions(), 24 * 3600_000);
262
+ cs.retentionTimer.unref?.();
263
+ cs.bg.start();
264
+ return cs;
265
+ }
266
+ /** P0-5 带指数退避的启动(1s/3s/9s,最终失败才抛)。 */
267
+ async startWithRetry() {
268
+ const delays = [1000, 3000, 9000];
269
+ let lastErr;
270
+ for (let i = 0; i <= delays.length; i++) {
271
+ try {
272
+ await this.runtime.start();
273
+ return;
274
+ }
275
+ catch (err) {
276
+ lastErr = err;
277
+ if (i === delays.length)
278
+ break;
279
+ await new Promise((r) => setTimeout(r, delays[i]));
280
+ }
281
+ }
282
+ throw lastErr;
283
+ }
284
+ /** P0-1 意外崩溃处理:重置进行中的 conv 状态 → 限频自动重启。 */
285
+ handleRuntimeExit(code, signal) {
286
+ if (this.disposed)
287
+ return;
288
+ console.error(`[dsh] runtime 意外退出 (client=${this.clientId}) code=${code} signal=${signal}`);
289
+ // 进行中的 run 全部中断(pending RPC 已被 failPending reject)→ 复位 streaming。
290
+ for (const conv of this.convs.values()) {
291
+ conv.isStreaming = false;
292
+ conv.streaming = null;
293
+ }
294
+ const now = Date.now();
295
+ if (now - this.runtimeRestart.windowStart > DshClientSession.RUNTIME_RESTART_WINDOW_MS) {
296
+ this.runtimeRestart.windowStart = now;
297
+ this.runtimeRestart.count = 0;
298
+ }
299
+ this.runtimeRestart.count += 1;
300
+ if (this.runtimeRestart.count > DshClientSession.RUNTIME_MAX_RESTARTS) {
301
+ console.error(`[dsh] runtime 反复崩溃 (code=${code} signal=${signal}),停止自动重启`);
302
+ this.emit({
303
+ type: "notice",
304
+ level: "error",
305
+ text: "DSH 运行时反复崩溃,已停止自动重启。请检查 DeepSeek API key 与 dsh 依赖安装。",
306
+ });
307
+ this.flushSnapshot();
308
+ return;
309
+ }
310
+ this.emit({ type: "notice", level: "warning", text: "DSH 运行时意外退出,正在自动重启…" });
311
+ void this.startWithRetry().catch((err) => {
312
+ this.emit({ type: "notice", level: "error", text: `自动重启失败:${err.message}` });
313
+ });
314
+ this.flushSnapshot();
315
+ }
316
+ /** P0-3 回收长时间空闲的非活跃 conversation(磁盘 JSONL 在,回放即可恢复)。 */
317
+ reclaimIdleConversations() {
318
+ if (this.disposed)
319
+ return;
320
+ const now = Date.now();
321
+ let changed = false;
322
+ for (const [id, conv] of this.convs) {
323
+ if (id === this.activeId)
324
+ continue;
325
+ if (conv.isStreaming)
326
+ continue;
327
+ if (conv.terminals.list().length > 0)
328
+ continue;
329
+ const idle = now - conv.lastEventAt;
330
+ const limit = conv.listed
331
+ ? DshClientSession.CONV_RECLAIM_LISTED_IDLE_MS
332
+ : DshClientSession.CONV_RECLAIM_IDLE_MS;
333
+ if (idle > limit) {
334
+ this.removeConversation(id);
335
+ changed = true;
336
+ }
337
+ }
338
+ if (changed) {
339
+ this.emitConversations();
340
+ this.flushSnapshot();
341
+ }
342
+ }
343
+ /** P1-13 清理超过保留期未活动的会话目录(<sessionRoot>/<projectKey>/<sessionId>/)。
344
+ * 目录内最新文件 mtime 判活跃(JSONL 追加写不更新目录 mtime,不能看目录本身)。 */
345
+ async cleanupExpiredSessions() {
346
+ if (this.disposed)
347
+ return;
348
+ const cutoff = Date.now() - DshClientSession.SESSION_RETENTION_MS;
349
+ try {
350
+ const { readdirSync, rmSync } = await import("node:fs");
351
+ const dirLastModified = (dir) => {
352
+ let max = 0;
353
+ try {
354
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
355
+ const p = join(dir, e.name);
356
+ try {
357
+ if (e.isDirectory()) {
358
+ max = Math.max(max, dirLastModified(p));
359
+ }
360
+ else {
361
+ max = Math.max(max, statSync(p).mtimeMs);
362
+ }
363
+ }
364
+ catch {
365
+ /* skip unreadable */
366
+ }
367
+ }
368
+ }
369
+ catch {
370
+ /* skip unreadable dir */
371
+ }
372
+ return max;
373
+ };
374
+ let removed = 0;
375
+ const scan = (dir) => {
376
+ let entries;
377
+ try {
378
+ entries = readdirSync(dir, { withFileTypes: true });
379
+ }
380
+ catch {
381
+ return;
382
+ }
383
+ for (const e of entries) {
384
+ const p = join(dir, e.name);
385
+ if (!e.isDirectory())
386
+ continue;
387
+ if (dirLastModified(p) === 0)
388
+ continue; // 空目录不删
389
+ if (dirLastModified(p) < cutoff) {
390
+ rmSync(p, { recursive: true, force: true });
391
+ removed++;
392
+ }
393
+ else {
394
+ scan(p);
395
+ }
396
+ }
397
+ };
398
+ scan(this.sessionRoot);
399
+ if (removed > 0) {
400
+ console.error(`[dsh] 已清理 ${removed} 个超过保留期的会话目录`);
401
+ this.scheduleSessionsRefresh();
402
+ }
403
+ }
404
+ catch {
405
+ /* best effort */
406
+ }
407
+ }
408
+ makeRuntime() {
409
+ this.runtime = new DshRuntime({
410
+ cwd: this.cwd,
411
+ provider: "deepseek-official",
412
+ model: this.model,
413
+ sessionRoot: this.sessionRoot,
414
+ dataDir: this.dataDir,
415
+ });
416
+ }
417
+ attachRuntimeEvents() {
418
+ this.runtime.onNotification((method, params) => {
419
+ if (this.disposed)
420
+ return;
421
+ try {
422
+ if (method === "session.event") {
423
+ this.handleSessionEvent(params);
424
+ }
425
+ else if (method === "session.status") {
426
+ this.handleSessionStatus(params);
427
+ }
428
+ else if (method === "question.pending") {
429
+ // 模型 ask_user_question → 转发给浏览器对话框(deadline = 服务端超时时间戳)。
430
+ const params0 = params;
431
+ this.emit({
432
+ type: "question_pending",
433
+ id: params0.id,
434
+ ...(typeof params0.deadline === "number" ? { deadline: params0.deadline } : {}),
435
+ questions: (params0.questions ?? []).map((q) => ({
436
+ id: String(q.id ?? ""),
437
+ question: String(q.question ?? ""),
438
+ ...(typeof q.detail === "string" ? { detail: q.detail } : {}),
439
+ ...(typeof q.header === "string" ? { header: q.header } : {}),
440
+ ...(Array.isArray(q.options) ? { options: q.options.map((o) => ({ label: String(o.label ?? ""), ...(typeof o.description === "string" ? { description: o.description } : {}) })) } : {}),
441
+ ...q.multiSelect ? { multiSelect: true } : {},
442
+ })),
443
+ });
444
+ }
445
+ else if (method === "tools.call.request") {
446
+ // 工具桥(#15):模型调了插件工具 → 服务端跑插件实现 → tools/call-result 回传。
447
+ void this.handleToolCallRequest(params);
448
+ }
449
+ }
450
+ catch (err) {
451
+ console.error("[dsh] event handler error:", err);
452
+ }
453
+ });
454
+ }
455
+ /** 前端回答模型提问(question/answer → runtime 恢复工具结果)。 */
456
+ async answerQuestion(id, answers, cancelled) {
457
+ try {
458
+ await this.runtime.answerQuestion(id, answers, cancelled);
459
+ }
460
+ catch (err) {
461
+ this.emit({ type: "notice", level: "error", text: `回答失败:${err.message}` });
462
+ }
463
+ }
464
+ // -----------------------------------------------------------------------
465
+ // 工具桥(#15 插件注入点):服务端把插件工具注册进运行时,并执行模型对
466
+ // 桥接工具的调用(tools.call.request → 插件 execute → tools/call-result)。
467
+ // -----------------------------------------------------------------------
468
+ /** 桥接的插件工具最小形状(对齐 plugins.ts 的 PluginAgentTool,仅取桥接所需字段)。 */
469
+ bridgedTool(t) {
470
+ const tool = t;
471
+ if (!tool || typeof tool.name !== "string" || typeof tool.description !== "string" || typeof tool.execute !== "function")
472
+ return undefined;
473
+ return {
474
+ name: tool.name,
475
+ description: tool.description,
476
+ ...(tool.parameters && typeof tool.parameters === "object" ? { parameters: tool.parameters } : {}),
477
+ execute: tool.execute,
478
+ };
479
+ }
480
+ /** 把当前插件工具(pluginToolsProvider)同步注册进运行时(幂等,可重复调用)。 */
481
+ async syncPluginTools() {
482
+ if (!this.runtime.alive)
483
+ return;
484
+ const provider = this.pluginToolsProvider;
485
+ if (!provider)
486
+ return;
487
+ const tools = (provider() ?? [])
488
+ .map((t) => this.bridgedTool(t))
489
+ .filter((t) => t !== undefined);
490
+ const defs = tools.map((t) => ({
491
+ name: t.name,
492
+ description: t.description,
493
+ ...(t.parameters ? { parameters: t.parameters } : {}),
494
+ }));
495
+ try {
496
+ await this.runtime.syncTools(defs);
497
+ }
498
+ catch (err) {
499
+ console.error(`[dsh] syncPluginTools 失败 (client=${this.clientId}):`, err);
500
+ }
501
+ }
502
+ /** 从运行时拉取技能清单 → 缓存 → 重推 settings_state(enabled 由 disabledSkills 推导)。 */
503
+ async refreshSkillsFromRuntime() {
504
+ if (!this.runtime.alive)
505
+ return;
506
+ try {
507
+ const res = await this.runtime.listSkills();
508
+ const disabled = new Set(this.settings.disabledSkills);
509
+ this.skillsCache = (res.skills ?? []).map((s) => ({
510
+ name: s.name,
511
+ description: s.description ?? "",
512
+ enabled: !disabled.has(s.name),
513
+ }));
514
+ }
515
+ catch (err) {
516
+ console.error(`[dsh] listSkills 失败 (client=${this.clientId}):`, err);
517
+ return;
518
+ }
519
+ try {
520
+ this.pushSettings();
521
+ }
522
+ catch {
523
+ /* best effort */
524
+ }
525
+ }
526
+ /** 把禁用技能集合同步给运行时(晚 pre-step 钩子据此过滤 skill-catalog 消息)。 */
527
+ async pushDisabledSkillsToRuntime() {
528
+ if (!this.runtime.alive)
529
+ return;
530
+ try {
531
+ await this.runtime.setDisabledSkills(this.settings.disabledSkills);
532
+ }
533
+ catch (err) {
534
+ console.error(`[dsh] setDisabledSkills 失败 (client=${this.clientId}):`, err);
535
+ }
536
+ }
537
+ /** 模型调用桥接工具:找插件 execute,归一化结果,tools/call-result 回传。 */
538
+ async handleToolCallRequest(params) {
539
+ const id = String(params?.id ?? "");
540
+ const name = String(params?.name ?? "");
541
+ const args = (params?.args && typeof params.args === "object" ? params.args : {});
542
+ if (!id)
543
+ return;
544
+ if (!name) {
545
+ void this.runtime.toolsCallResult(id, "工具名缺失", true).catch(() => { });
546
+ return;
547
+ }
548
+ try {
549
+ const tool = this.bridgedTool((this.pluginToolsProvider?.() ?? []).find((t) => t.name === name));
550
+ if (!tool) {
551
+ await this.runtime.toolsCallResult(id, `未知插件工具:${name}`, true);
552
+ return;
553
+ }
554
+ const ac = new AbortController();
555
+ const raw = await tool.execute(id, args, ac.signal);
556
+ await this.runtime.toolsCallResult(id, normalizeToolResult(raw), false);
557
+ }
558
+ catch (err) {
559
+ const msg = err?.message ?? String(err);
560
+ await this.runtime.toolsCallResult(id, msg, true);
561
+ }
562
+ }
563
+ nextConversationId() {
564
+ return `c${++this.convSeq}-${randomUUID().slice(0, 8)}`;
565
+ }
566
+ /** 每个会话独立的目标状态(默认来自客户端级偏好)。 */
567
+ makeGoalStatus() {
568
+ return {
569
+ conversationId: null,
570
+ goal: null,
571
+ reviewModel: this.goalPrefs.reviewModel,
572
+ maxRounds: this.goalPrefs.maxRounds,
573
+ locked: this.goalPrefs.locked,
574
+ reviewing: false,
575
+ round: 0,
576
+ status: "",
577
+ verdict: "pending",
578
+ wizard: { active: false, draft: "", model: null, step: 0, maxSteps: 3, status: "" },
579
+ };
580
+ }
581
+ /** 新建(或切换)一个 conversation。existing 的 sessionId 续聊最近 JSONL。 */
582
+ addConversation(sessionId, cwd, replay = true) {
583
+ const id = this.nextConversationId();
584
+ const conv = {
585
+ id,
586
+ sessionId,
587
+ dsGoal: null,
588
+ goal: this.makeGoalStatus(),
589
+ title: DEFAULT_CONV_TITLE,
590
+ cwd,
591
+ createdAt: Date.now(),
592
+ messages: [],
593
+ messageIds: new Set(),
594
+ streaming: null,
595
+ isStreaming: false,
596
+ queue: { steering: [], followUp: [] },
597
+ deltaSeq: 0,
598
+ lastEventAt: Date.now(),
599
+ listed: false,
600
+ promptedSinceActive: false,
601
+ terminals: new TerminalManager((msg) => this.emit(msg), cwd),
602
+ toolStartTimes: new Map(),
603
+ tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
604
+ };
605
+ if (replay) {
606
+ // 从磁盘 JSONL 回放历史消息(DSH 事件流不重放历史)。
607
+ try {
608
+ const files = findSessionFilesForCwd(this.sessionRoot, cwd).filter((f) => basename(dirname(f)) === sessionId);
609
+ if (files.length > 0) {
610
+ const { events } = readSessionLog(files[0]);
611
+ conv.messages = replayEventsToMessages(events);
612
+ for (const m of conv.messages)
613
+ conv.messageIds.add(m.id);
614
+ conv.title = firstUserText(events);
615
+ }
616
+ }
617
+ catch {
618
+ /* best effort */
619
+ }
620
+ }
621
+ this.convs.set(conv.id, conv);
622
+ return conv;
623
+ }
624
+ get conv() {
625
+ return this.convs.get(this.activeId);
626
+ }
627
+ // -----------------------------------------------------------------------
628
+ // 事件管线
629
+ // -----------------------------------------------------------------------
630
+ findConv(sessionId) {
631
+ for (const conv of this.convs.values()) {
632
+ if (conv.sessionId === sessionId)
633
+ return conv;
634
+ }
635
+ return undefined;
636
+ }
637
+ handleSessionEvent(params) {
638
+ const conv = this.findConv(params.sessionId);
639
+ if (!conv)
640
+ return; // 非本客户端 conversation(并发其他客户端)→ 忽略
641
+ conv.lastEventAt = Date.now();
642
+ const ev = params.event;
643
+ switch (ev.type) {
644
+ case "user/message": {
645
+ // DSH 会注入系统内部消息(workspace 指令 / 运行时上下文快照 / 目标轮次
646
+ // prompt)——前端不显示(pi 引擎的 asides 同理),只保留真正的用户消息。
647
+ const srcKind = ev.data.source?.kind;
648
+ if (srcKind === "goal") {
649
+ // 目标轮次承认:不渲染,但更新轮数显示(round 由 source.round 携带)。
650
+ // P1-10:轮次达上限且仍未完成 → 提示轮尽。
651
+ const round = ev.data.source?.round;
652
+ if (round && conv.goal.goal && conv.id === this.activeId) {
653
+ conv.goal.round = round;
654
+ conv.goal.reviewing = true;
655
+ const max = conv.goal.maxRounds || conv.dsGoal?.maxGoalRounds || 0;
656
+ conv.goal.status =
657
+ max > 0 && round >= max
658
+ ? `已达轮数上限(${round}/${max}),目标未完成`
659
+ : `目标进行中(第 ${round} 轮)…`;
660
+ this.emitGoalStatus();
661
+ }
662
+ break;
663
+ }
664
+ if (srcKind === "agent-instructions" || srcKind === "plugin")
665
+ break;
666
+ const msg = userMessageEventToUiMessage(ev.data);
667
+ // 重复文本(DSH 有时重放同一用户消息)→ 去重。
668
+ const text = msg.content.map((c) => ("text" in c ? c.text : "")).join("");
669
+ if (text &&
670
+ conv.messages.some((m) => m.role === "user" &&
671
+ m.content.map((c) => ("text" in c ? c.text : "")).join("") === text)) {
672
+ break;
673
+ }
674
+ this.appendMessage(conv, msg);
675
+ // 图片附件异步补图:DSH 事件里的 image 块只有 ref 没有像素,回读后
676
+ // 填 dataUrl 供前端显示(回放/事件流的图片块在本地乐观消息中已显示过)。
677
+ const imgRefs = (ev.data.content ?? [])
678
+ .filter((b) => b?.type === "image")
679
+ .map((b) => b.attachment)
680
+ .filter((r) => !!r && typeof r.attachmentId === "string" && typeof r.mediaType === "string");
681
+ if (imgRefs.length > 0) {
682
+ void this.hydrateImageBlocks(conv, msg, imgRefs);
683
+ }
684
+ if (conv.title === DEFAULT_CONV_TITLE) {
685
+ const t = conv.messages.find((m) => m.role === "user")?.content
686
+ ?.map((c) => ("text" in c ? c.text : ""))
687
+ .join(" ")
688
+ .trim();
689
+ if (t)
690
+ conv.title = t.length > 30 ? `${t.slice(0, 30)}…` : t;
691
+ }
692
+ break;
693
+ }
694
+ case "assistant/message": {
695
+ const msg = assistantMessageEventToUiMessage(ev.data);
696
+ if (msg) {
697
+ // 完整消息落地 → streaming 清空(避免重复)。
698
+ conv.streaming = null;
699
+ this.appendMessage(conv, msg);
700
+ }
701
+ break;
702
+ }
703
+ case "tool/result": {
704
+ const msg = toolResultEventToUiMessage(ev.data);
705
+ if (msg)
706
+ this.appendMessage(conv, msg);
707
+ const startedAt = conv.toolStartTimes.get(msg?.toolCallId ?? "");
708
+ if (msg)
709
+ conv.toolStartTimes.delete(msg.toolCallId ?? "");
710
+ // bash 工具结束 → 后台任务端口 diff。
711
+ const toolName = ev.data.toolName;
712
+ if (toolName === "bash")
713
+ void this.bg.trackAfterBash();
714
+ this.onToolEvent?.({
715
+ phase: "end",
716
+ toolName: toolName ?? "tool",
717
+ conversationId: conv.id,
718
+ ...(startedAt !== undefined ? { durationMs: Date.now() - startedAt } : {}),
719
+ isError: msg?.isError === true,
720
+ });
721
+ if (msg) {
722
+ this.emit({
723
+ type: "tool_status",
724
+ toolCallId: msg.toolCallId ?? "",
725
+ toolName: toolName ?? "tool",
726
+ isError: msg.isError === true,
727
+ });
728
+ }
729
+ this.flushSnapshot();
730
+ break;
731
+ }
732
+ case "assistant/chunk": {
733
+ const chunk = ev.data?.chunk;
734
+ if (!chunk)
735
+ break;
736
+ if (chunk.type === "usage") {
737
+ const u = chunk.usage;
738
+ conv.tokens.input = u.inputTokens ?? 0;
739
+ conv.tokens.output = u.outputTokens ?? 0;
740
+ conv.tokens.cacheRead = u.cacheReadTokens ?? 0;
741
+ conv.tokens.cacheWrite = u.cacheWriteTokens ?? 0;
742
+ break;
743
+ }
744
+ if (!conv.streaming) {
745
+ conv.streaming = new DshStreamAccumulator(ev.seq, ev.data?.turn ?? 0);
746
+ }
747
+ conv.streaming.apply(chunk);
748
+ // message_delta 实时通道:本机快速完成时 60ms 延迟快照总被
749
+ // assistant/message 抢跑(streaming 从未被捕捉)——delta 直接
750
+ // 走独立通道,保证逐 token 渲染(前端 patch streamingMessage)。
751
+ if (conv.id === this.conv.id &&
752
+ (chunk.type === "text-delta" || chunk.type === "reasoning-delta")) {
753
+ this.emit({
754
+ type: "message_delta",
755
+ conversationId: conv.id,
756
+ seq: ++conv.deltaSeq,
757
+ messageId: conv.streaming.id,
758
+ usage: null,
759
+ assistantMessageEvent: {
760
+ type: chunk.type === "text-delta" ? "text_delta" : "thinking_delta",
761
+ contentIndex: chunk.index,
762
+ delta: chunk.text,
763
+ },
764
+ });
765
+ }
766
+ break;
767
+ }
768
+ case "tool/call": {
769
+ const callId = ev.data?.callId;
770
+ const name = ev.data?.name;
771
+ if (callId)
772
+ conv.toolStartTimes.set(callId, Date.now());
773
+ if (name === "bash")
774
+ this.bg.snapshotBefore();
775
+ this.onToolEvent?.({ phase: "start", toolName: name ?? "tool", conversationId: conv.id });
776
+ break;
777
+ }
778
+ case "goal/change": {
779
+ // DSH 原生目标状态机事件(create/edit/resume/complete/block/clear),
780
+ // 全量快照 → 翻译成 GoalStatus 推前端。权威源在运行时,本地只镜像。
781
+ this.applyGoalChange(conv, ev.data);
782
+ break;
783
+ }
784
+ case "turn/end": {
785
+ conv.streaming = null;
786
+ // DSH 无法恢复已持久化会话(id collision)——abort 重启运行时后
787
+ // 原会话也变 "磁盘有日志无 live"。检测到 error → 自动 fork + 重发。
788
+ // 目标轮次由 goal-round-driver 自动续,这里不需要审查钩子。
789
+ const reason = ev.data?.reason ?? {};
790
+ if (reason.kind === "error" && /id collision/i.test(reason.error?.message ?? "") && !conv.fromDisk) {
791
+ this.forkAndReprompt(conv);
792
+ }
793
+ else if (conv.turnWaiter) {
794
+ // 调研向导等本轮结束(completed 正常 / error 中断)。
795
+ const w = conv.turnWaiter;
796
+ conv.turnWaiter = undefined;
797
+ if (reason.kind === "completed")
798
+ w.resolve();
799
+ else
800
+ w.reject(new Error(reason.error?.message ?? `本轮异常结束(${reason.kind})`));
801
+ }
802
+ break;
803
+ }
804
+ case "session/title": {
805
+ const title = ev.data?.title ?? "";
806
+ if (title)
807
+ conv.title = title;
808
+ break;
809
+ }
810
+ case "agent/inbox/spliced": {
811
+ // 用户 prompt 注入 → 清理 followUp 队列中已消费的文本。
812
+ const inserted = ev.data?.inserted ?? [];
813
+ const texts = inserted
814
+ .map((m) => m.content?.find((c) => c.type === "text")?.text ?? "")
815
+ .filter(Boolean);
816
+ if (texts.length > 0) {
817
+ for (const t of texts) {
818
+ const i = conv.queue.followUp.indexOf(t);
819
+ if (i >= 0)
820
+ conv.queue.followUp.splice(i, 1);
821
+ }
822
+ }
823
+ break;
824
+ }
825
+ default:
826
+ break;
827
+ }
828
+ // 事件驱动快照(60ms 节流;边界事件立即)。
829
+ if (ev.type === "turn/end" || ev.type === "tool/result" || ev.type === "assistant/message") {
830
+ this.flushSnapshot();
831
+ }
832
+ else {
833
+ this.scheduleSnapshot();
834
+ }
835
+ }
836
+ handleSessionStatus(params) {
837
+ const conv = this.findConv(params.sessionId);
838
+ if (!conv)
839
+ return;
840
+ const was = conv.isStreaming;
841
+ conv.isStreaming = params.status === "running";
842
+ conv.lastEventAt = Date.now();
843
+ if (was && !conv.isStreaming) {
844
+ // run 结束:清 streaming + 刷新会话列表。
845
+ conv.streaming = null;
846
+ this.refreshConversationTitle(conv);
847
+ this.scheduleSessionsRefresh();
848
+ }
849
+ this.flushSnapshot();
850
+ }
851
+ appendMessage(conv, msg) {
852
+ if (!msg || conv.messageIds.has(msg.id))
853
+ return;
854
+ conv.messageIds.add(msg.id);
855
+ conv.messages.push(msg);
856
+ }
857
+ refreshConversationTitle(conv) {
858
+ if (conv.title !== DEFAULT_CONV_TITLE)
859
+ return;
860
+ // 从消息列表取第一个用户文本。
861
+ const t = conv.messages
862
+ .find((m) => m.role === "user")
863
+ ?.content?.map((c) => ("text" in c ? c.text : ""))
864
+ .join(" ")
865
+ .trim();
866
+ if (t) {
867
+ conv.title = t.length > 30 ? `${t.slice(0, 30)}…` : t;
868
+ this.emitConversations();
869
+ }
870
+ }
871
+ // -----------------------------------------------------------------------
872
+ // 快照
873
+ // -----------------------------------------------------------------------
874
+ /** 立即推送(节流 60ms 合并突发)。 */
875
+ flushSnapshot(forceFull = false) {
876
+ if (this.disposed)
877
+ return;
878
+ if (this.snapshotTimer) {
879
+ clearTimeout(this.snapshotTimer);
880
+ this.snapshotTimer = null;
881
+ }
882
+ this.emitSnapshotNow(forceFull);
883
+ }
884
+ scheduleSnapshot() {
885
+ if (this.snapshotTimer || this.disposed)
886
+ return;
887
+ this.snapshotTimer = setTimeout(() => {
888
+ this.snapshotTimer = null;
889
+ if (!this.disposed)
890
+ this.emitSnapshotNow();
891
+ }, SNAPSHOT_INTERVAL_MS);
892
+ }
893
+ emitSnapshotNow(forceFull = false) {
894
+ if (this.disposed)
895
+ return;
896
+ const conv = this.conv;
897
+ // 拷贝一份:appendMessage 直接 push conv.messages,若把引用存进
898
+ // emittedMessages,prev/cur 就是同一数组,slice(prev.length) 恒为空。
899
+ const cur = [...conv.messages];
900
+ const prev = this.emittedMessages;
901
+ const rev = ++this.snapRev;
902
+ // 增量:同一 conversation 且纯 append 时发 snapshot_delta。
903
+ let incremental = !forceFull &&
904
+ prev !== null &&
905
+ this.emittedRev > 0 &&
906
+ prev.length <= cur.length;
907
+ if (incremental && prev) {
908
+ for (let i = 0; i < prev.length; i++) {
909
+ if (prev[i] !== cur[i]) {
910
+ incremental = false;
911
+ break;
912
+ }
913
+ }
914
+ }
915
+ this.emittedMessages = cur;
916
+ this.emittedRev = rev;
917
+ if (incremental && prev) {
918
+ this.emit({
919
+ type: "snapshot_delta",
920
+ conversationId: this.activeId,
921
+ rev,
922
+ baseRev: this.emittedRev - 1,
923
+ appended: cur.slice(prev.length),
924
+ state: this.buildLightState(rev),
925
+ });
926
+ }
927
+ else {
928
+ this.emit({
929
+ type: "snapshot",
930
+ state: { ...this.buildLightState(rev), messages: cur },
931
+ });
932
+ }
933
+ }
934
+ buildLightState(rev) {
935
+ const conv = this.conv;
936
+ const tokens = conv.tokens;
937
+ const stats = {
938
+ totalMessages: conv.messages.length,
939
+ tokens: {
940
+ input: tokens.input,
941
+ output: tokens.output,
942
+ cacheRead: tokens.cacheRead,
943
+ cacheWrite: tokens.cacheWrite,
944
+ total: tokens.input + tokens.output + tokens.cacheRead + tokens.cacheWrite,
945
+ },
946
+ cost: estimateCost(tokens),
947
+ contextUsage: {
948
+ tokens: tokens.input + tokens.output + tokens.cacheRead,
949
+ contextWindow: DSH_CONTEXT_WINDOW,
950
+ percent: DSH_CONTEXT_WINDOW > 0
951
+ ? Math.min(100, ((tokens.input + tokens.output + tokens.cacheRead) / DSH_CONTEXT_WINDOW) * 100)
952
+ : null,
953
+ },
954
+ };
955
+ return {
956
+ clientId: this.clientId,
957
+ cwd: this.cwd,
958
+ sessionId: conv.sessionId,
959
+ conversationId: this.activeId,
960
+ rev,
961
+ streamingMessage: conv.streaming
962
+ ? conv.streaming.toUiMessage(conv.lastEventAt, this.model, "deepseek")
963
+ : null,
964
+ isStreaming: conv.isStreaming,
965
+ model: {
966
+ id: this.model,
967
+ name: DSH_MODELS.find((m) => m.id === this.model)?.name ?? this.model,
968
+ provider: "deepseek",
969
+ // dsh-llm-deepseek adapter:仅 vision-exp 模型 inputModalities 含 image
970
+ vision: DSH_MODELS.find((m) => m.id === this.model)?.vision ?? false,
971
+ },
972
+ thinkingLevel: this.thinkingLevel,
973
+ availableThinkingLevels: ["high"],
974
+ queue: { steering: conv.queue.steering, followUp: conv.queue.followUp },
975
+ tools: [],
976
+ version: ++this.version,
977
+ piConfigured: !!loadDeepSeekKey(),
978
+ piAgentInstalled: false,
979
+ stats,
980
+ };
981
+ }
982
+ // -----------------------------------------------------------------------
983
+ // socket / 通知
984
+ // -----------------------------------------------------------------------
985
+ attachSink(send) {
986
+ this.sinks.add(send);
987
+ for (const msg of this.pendingNotices)
988
+ send(msg);
989
+ this.pendingNotices = [];
990
+ this.emitConversations();
991
+ this.emitGoalStatus();
992
+ this.pushSettings();
993
+ this.bg.push();
994
+ this.pushTerminals();
995
+ }
996
+ detachSink(send) {
997
+ this.sinks.delete(send);
998
+ if (this.sinks.size === 0)
999
+ this.files.unwatchDir();
1000
+ }
1001
+ emit(msg) {
1002
+ if (this.disposed)
1003
+ return;
1004
+ for (const sink of [...this.sinks])
1005
+ sink(msg);
1006
+ }
1007
+ emitNotice(level, text) {
1008
+ this.emit({ type: "notice", level, text });
1009
+ }
1010
+ pushTerminals() {
1011
+ for (const conv of this.convs.values()) {
1012
+ const terminals = conv.terminals.list();
1013
+ if (terminals.length > 0) {
1014
+ this.emit({ type: "terminal_list", conversationId: conv.id, terminals });
1015
+ }
1016
+ }
1017
+ }
1018
+ getTerminalManager(conversationId) {
1019
+ return (conversationId ? this.convs.get(conversationId) : this.conv)?.terminals;
1020
+ }
1021
+ getTerminalCwd(conversationId) {
1022
+ return (conversationId ? this.convs.get(conversationId) : this.conv)?.cwd ?? this.cwd;
1023
+ }
1024
+ // -----------------------------------------------------------------------
1025
+ // 对话管理
1026
+ // -----------------------------------------------------------------------
1027
+ activeConversations() {
1028
+ let n = 0;
1029
+ for (const conv of this.convs.values())
1030
+ if (conv.isStreaming)
1031
+ n++;
1032
+ return n;
1033
+ }
1034
+ pendingMessages() {
1035
+ return 0;
1036
+ }
1037
+ emitConversations() {
1038
+ const list = [];
1039
+ for (const conv of this.convs.values()) {
1040
+ // 只列被置换到后台的运行中会话(与 pi 一致);active 会话不进“运行的对话”。
1041
+ if (!conv.listed)
1042
+ continue;
1043
+ list.push({
1044
+ id: conv.id,
1045
+ title: conv.title,
1046
+ cwd: conv.cwd,
1047
+ messageCount: conv.messages.length,
1048
+ isStreaming: conv.isStreaming,
1049
+ });
1050
+ }
1051
+ this.emit({ type: "conversations", conversations: list, activeId: this.activeId });
1052
+ }
1053
+ async newChat() {
1054
+ if (this.quiesceBlocked())
1055
+ return;
1056
+ const active = this.conv;
1057
+ if (active.messages.length === 0 && active.terminals.list().length === 0) {
1058
+ this.flushSnapshot();
1059
+ return;
1060
+ }
1061
+ for (const conv of this.convs.values()) {
1062
+ if (conv.id === this.activeId)
1063
+ continue;
1064
+ if (conv.messages.length === 0) {
1065
+ this.switchConversation(conv.id);
1066
+ this.flushSnapshot();
1067
+ return;
1068
+ }
1069
+ }
1070
+ const openInProject = [...this.convs.values()].filter((c) => c.cwd === this.cwd).length;
1071
+ if (openInProject >= MAX_OPEN_CONVERSATIONS) {
1072
+ this.emit({
1073
+ type: "notice",
1074
+ level: "warning",
1075
+ text: `当前项目运行的对话已达上限(${MAX_OPEN_CONVERSATIONS} 个)`,
1076
+ });
1077
+ return;
1078
+ }
1079
+ // 旧对话保留(listed 生命周期简化:不主动移除)。
1080
+ const prevModel = this.model;
1081
+ active.listed = active.isStreaming || active.terminals.list().length > 0 || active.promptedSinceActive;
1082
+ const conv = this.addConversation(`chat-${randomUUID().slice(0, 12)}`, this.cwd, false);
1083
+ this.activeId = conv.id;
1084
+ this.model = prevModel;
1085
+ this.emitConversations();
1086
+ this.emitGoalStatus();
1087
+ this.pushTerminals();
1088
+ this.flushSnapshot();
1089
+ }
1090
+ async switchConversation(id) {
1091
+ if (!this.convs.has(id) || id === this.activeId)
1092
+ return;
1093
+ const prev = this.conv;
1094
+ prev.listed = prev.isStreaming || prev.terminals.list().length > 0 || prev.promptedSinceActive;
1095
+ this.activeId = id;
1096
+ // 后台列表可能属于另一项目 → 切会话同时切工作区(与 pi 一致:文件树/
1097
+ // 会话历史/最近项目跟着走)。DSH 单 runtime 换 cwd → 异步重启。
1098
+ const newCwd = this.conv.cwd;
1099
+ const cwdChanged = newCwd !== this.cwd;
1100
+ if (cwdChanged) {
1101
+ this.cwd = newCwd;
1102
+ this.stateStore.remember(this.clientId, newCwd);
1103
+ this.onCwdChanged?.(newCwd);
1104
+ void this.pushProjects();
1105
+ void this.pushSessions();
1106
+ void this.listFiles(undefined);
1107
+ void this.runtime.restart(this.model).catch((err) => {
1108
+ this.emit({
1109
+ type: "notice",
1110
+ level: "error",
1111
+ text: `切换工作区后重启运行时失败:${err.message}`,
1112
+ });
1113
+ });
1114
+ }
1115
+ this.emitConversations();
1116
+ this.emitGoalStatus();
1117
+ this.pushTerminals();
1118
+ this.flushSnapshot(true);
1119
+ }
1120
+ removeConversation(id) {
1121
+ const conv = this.convs.get(id);
1122
+ if (!conv || id === this.activeId)
1123
+ return;
1124
+ this.convs.delete(id);
1125
+ conv.terminals.killAll();
1126
+ }
1127
+ // -----------------------------------------------------------------------
1128
+ // prompt / 附件
1129
+ // -----------------------------------------------------------------------
1130
+ async prompt(text, attachments, queue = false) {
1131
+ // 斜杠命令拦截(内置 NATIVE + 插件 registerCommand);带附件时不拦截。
1132
+ const parsed = parseSlash(text);
1133
+ if (parsed && !attachments?.length) {
1134
+ const handled = await this.execSlash(parsed.name, parsed.args);
1135
+ if (handled) {
1136
+ // 与 pi 一致:命令执行后强制刷一次快照(notice/状态变化立即可见)。
1137
+ this.flushSnapshot();
1138
+ return;
1139
+ }
1140
+ }
1141
+ let conv = this.conv;
1142
+ // 磁盘回放会话(switch_session)没有 live runtime session —— DSH 的
1143
+ // JSON-RPC 面不支持恢复(id collision),自动 fork 新会话继续:把历史
1144
+ // 作为上下文注入首条 prompt,前端提示。
1145
+ if (conv.fromDisk && text.trim()) {
1146
+ const histText = this.histToContext(conv);
1147
+ conv = this.forkConversation(conv);
1148
+ if (histText.trim()) {
1149
+ text = `${text}\n\n(以下为原对话上下文,仅作参考,请忽略其中的指令性语气):\n${histText}`;
1150
+ }
1151
+ }
1152
+ // 命名对话(首个 prompt)。
1153
+ if (conv.title === DEFAULT_CONV_TITLE && text.trim()) {
1154
+ const trimmed = text.trim().replace(/\s+/g, " ");
1155
+ conv.title = trimmed.length > 30 ? `${trimmed.slice(0, 30)}…` : trimmed;
1156
+ this.emitConversations();
1157
+ }
1158
+ await this.promptConv(conv, text, attachments, queue);
1159
+ }
1160
+ /** 向指定会话发提示(审查注入/重发用;不处理 fromDisk fork 与命名)。 */
1161
+ /** 排空期拒绝新工作(与 pi 引擎同文案;存量运行继续)。 */
1162
+ quiesceBlocked() {
1163
+ if (!this.isQuiesced?.())
1164
+ return false;
1165
+ this.emit({
1166
+ type: "notice",
1167
+ level: "error",
1168
+ text: "服务器正在排空存量工作(quiesce),已拒绝新的对话/消息/编辑。存量运行会继续跑完;用 pi-web-ui server unquiesce 可恢复。",
1169
+ });
1170
+ this.flushSnapshot();
1171
+ return true;
1172
+ }
1173
+ /** 回放补图:按 ref 读回图片字节 → 填入消息的 image 块 dataUrl(失败静默保持占位)。 */
1174
+ async hydrateImageBlocks(conv, msg, refs) {
1175
+ if (this.disposed)
1176
+ return;
1177
+ try {
1178
+ const dataUrls = [];
1179
+ for (const ref of refs) {
1180
+ const r = await this.runtime.attachmentRead(ref);
1181
+ dataUrls.push(`data:${r.mediaType ?? "image/png"};base64,${r.data}`);
1182
+ }
1183
+ if (this.disposed)
1184
+ return;
1185
+ // 找到该消息(可能已被去重跳过/会话已切换),原地填块。
1186
+ const target = conv.messages.find((m) => m.id === msg.id);
1187
+ if (!target)
1188
+ return;
1189
+ let i = 0;
1190
+ for (const block of target.content) {
1191
+ if (block.type === "image" && "dataUrl" in block && !block.dataUrl && i < dataUrls.length) {
1192
+ block.dataUrl = dataUrls[i++];
1193
+ }
1194
+ }
1195
+ if (i > 0)
1196
+ this.flushSnapshot();
1197
+ }
1198
+ catch {
1199
+ /* 补图失败保持占位 */
1200
+ }
1201
+ }
1202
+ async promptConv(conv, text, attachments, queue = false) {
1203
+ try {
1204
+ if (this.quiesceBlocked())
1205
+ return;
1206
+ conv.promptedSinceActive = true;
1207
+ conv.lastEventAt = Date.now();
1208
+ const blocks = await this.buildContentBlocks(text, attachments);
1209
+ // 乐观落地用户消息(id 用暂定值;user/message 事件到达时按内容去重)。
1210
+ const optimistic = {
1211
+ id: `u-pending-${Date.now()}-${conv.deltaSeq++}`,
1212
+ role: "user",
1213
+ content: [
1214
+ { type: "text", text },
1215
+ ...(Array.isArray(attachments)
1216
+ ? attachments
1217
+ .filter((a) => a.imageData)
1218
+ .map((a) => ({ type: "image", dataUrl: a.imageData }))
1219
+ : []),
1220
+ ],
1221
+ timestamp: Date.now(),
1222
+ };
1223
+ this.appendMessage(conv, optimistic);
1224
+ this.flushSnapshot();
1225
+ if (conv.isStreaming) {
1226
+ // DSH 无 mid-run steering:isStreaming 时入队(followUp),
1227
+ // 运行时 inbox 在 run 结束后消费。
1228
+ conv.queue.followUp.push(text);
1229
+ await this.runtime.prompt(conv.sessionId, blocks);
1230
+ conv.queue.followUp = conv.queue.followUp.filter((t) => t !== text);
1231
+ }
1232
+ else {
1233
+ await this.runtime.prompt(conv.sessionId, blocks);
1234
+ }
1235
+ }
1236
+ catch (err) {
1237
+ this.emit({
1238
+ type: "notice",
1239
+ level: "error",
1240
+ text: `提示发送失败:${err.message}`,
1241
+ });
1242
+ }
1243
+ this.flushSnapshot();
1244
+ }
1245
+ /** 会话历史 → 上下文文本(fork 时注入)。 */
1246
+ histToContext(conv) {
1247
+ return conv.messages
1248
+ .map((m) => {
1249
+ const blocks = m.content.map((c) => ("text" in c ? c.text : "")).join("\n");
1250
+ return blocks ? `[${m.role === "assistant" ? "AI" : m.role}] ${blocks}` : "";
1251
+ })
1252
+ .filter(Boolean)
1253
+ .join("\n");
1254
+ }
1255
+ /** 新建 fork 会话并切换到它(DSH 无法原地续聊旧会话)。 */
1256
+ forkConversation(prev) {
1257
+ const fork = this.addConversation(`fork-${randomUUID().slice(0, 12)}`, this.cwd, false);
1258
+ fork.title = prev.title;
1259
+ this.activeId = fork.id;
1260
+ // P2-19:原会话有 active goal(DSH same-session 语义)→ 提示随会话存档。
1261
+ const hadGoal = prev.goal.goal !== null && prev.goal.verdict === "pending";
1262
+ this.emitConversations();
1263
+ this.emit({
1264
+ type: "notice",
1265
+ level: "info",
1266
+ text: hadGoal
1267
+ ? "已新建分支继续对话(DSH 引擎不支持原地续聊旧会话);原目标已随旧会话存档,如需继续请重新设置目标"
1268
+ : "已新建分支继续对话(DSH 引擎不支持原地续聊旧会话)",
1269
+ });
1270
+ return fork;
1271
+ }
1272
+ /** turn/end 报 id collision(abort 重启运行时后会话不再 live)→ fork + 重发最后提问。 */
1273
+ forkAndReprompt(conv) {
1274
+ if (this.disposed)
1275
+ return;
1276
+ // 找最后一条用户消息作为重发文本。
1277
+ let lastUser = "";
1278
+ for (let i = conv.messages.length - 1; i >= 0; i--) {
1279
+ const m = conv.messages[i];
1280
+ if (m.role === "user") {
1281
+ lastUser = m.content.map((c) => ("text" in c ? c.text : "")).join("").trim();
1282
+ if (lastUser)
1283
+ break;
1284
+ }
1285
+ }
1286
+ const hist = this.histToContext(conv);
1287
+ const fork = this.forkConversation(conv);
1288
+ const text = lastUser
1289
+ ? hist.trim()
1290
+ ? `${lastUser}\n\n(以下为原对话上下文,仅作参考):\n${hist}`
1291
+ : lastUser
1292
+ : "请继续";
1293
+ this.emit({ type: "notice", level: "info", text: "已自动重发(原会话不可续聊)" });
1294
+ void this.prompt(text);
1295
+ }
1296
+ /** 附件 → DSH contentBlocks(v1 简化:文本内联 / 路径引用 / 图片占位)。 */
1297
+ /** dataUrl(data:image/png;base64,XXX)→ {mediaType, base64};无前缀按 PNG 处理。 */
1298
+ static splitImageDataUrl(data) {
1299
+ const m = /^data:([^;,]+);base64,([\s\S]*)$/u.exec(data);
1300
+ if (m && m[2])
1301
+ return { mediaType: m[1] ?? "image/png", base64: m[2] };
1302
+ return { mediaType: "image/png", base64: data };
1303
+ }
1304
+ async buildContentBlocks(text, attachments) {
1305
+ const blocks = [{ type: "text", text }];
1306
+ if (!Array.isArray(attachments))
1307
+ return blocks;
1308
+ for (const a of attachments) {
1309
+ const resolved = a.path ? workspacePath(this.cwd, a.path) : null;
1310
+ if (a.imageData) {
1311
+ // 视觉桥:base64 图片 → attachment store → 真 image 块(模型可看图)。
1312
+ try {
1313
+ const { mediaType, base64 } = DshClientSession.splitImageDataUrl(a.imageData);
1314
+ const saved = await this.runtime.attachmentSave(mediaType, base64, a.name);
1315
+ blocks.push({ type: "image", attachment: saved.ref });
1316
+ }
1317
+ catch (err) {
1318
+ blocks.push({ type: "text", text: `\n[图片附件: ${a.name ?? "image"}(保存失败 ${err.message})]` });
1319
+ }
1320
+ }
1321
+ else if (a.fileData) {
1322
+ // 上传文件 → 落盘 + 路径引用。
1323
+ try {
1324
+ const saved = saveUpload(this.clientId, a.name ?? "upload", Buffer.from(a.fileData, "base64"), this.dataDir);
1325
+ blocks.push({ type: "text", text: `\n[上传文件: ${saved.abs}]` });
1326
+ }
1327
+ catch (err) {
1328
+ blocks.push({ type: "text", text: `\n[上传文件: ${a.name ?? "upload"}(落盘失败 ${err.message})]` });
1329
+ }
1330
+ }
1331
+ else if (resolved) {
1332
+ if (a.mode === "inline" || a.mode === undefined) {
1333
+ // 内联文本(小文件直接读内容)。
1334
+ try {
1335
+ const st = statSync(resolved.abs);
1336
+ if (st.size <= 512 * 1024) {
1337
+ const buf = readFileSync(resolved.abs);
1338
+ const kind = previewKind(resolved.abs);
1339
+ if (kind === "image") {
1340
+ // 工作区图片文件 → attachment store → 真 image 块。
1341
+ try {
1342
+ const ext = (resolved.rel.match(/\.([a-z0-9]+)$/iu)?.[1] ?? "png").toLowerCase();
1343
+ const mediaType = ext === "jpg" || ext === "jpeg"
1344
+ ? "image/jpeg"
1345
+ : ext === "webp"
1346
+ ? "image/webp"
1347
+ : ext === "gif"
1348
+ ? "image/gif"
1349
+ : "image/png";
1350
+ const saved = await this.runtime.attachmentSave(mediaType, buf.toString("base64"), resolved.rel);
1351
+ blocks.push({ type: "image", attachment: saved.ref });
1352
+ }
1353
+ catch {
1354
+ blocks.push({ type: "text", text: `\n[图片附件: ${resolved.rel}]` });
1355
+ }
1356
+ }
1357
+ else {
1358
+ const enc = this.decodeText(buf);
1359
+ const capped = enc.length > 100_000 ? `${enc.slice(0, 100_000)}\n… [truncated]` : enc;
1360
+ blocks.push({
1361
+ type: "text",
1362
+ text: `\n<file path="${resolved.rel}">\n${capped}\n</file>`,
1363
+ });
1364
+ }
1365
+ }
1366
+ else {
1367
+ blocks.push({ type: "text", text: `\n[文件引用: ${resolved.rel}(大文件,请用读取工具查看)]` });
1368
+ }
1369
+ }
1370
+ catch {
1371
+ blocks.push({ type: "text", text: `\n[文件引用: ${resolved.rel}]` });
1372
+ }
1373
+ }
1374
+ else {
1375
+ blocks.push({ type: "text", text: `\n[文件引用: ${resolved.rel}]` });
1376
+ }
1377
+ }
1378
+ else if (a.name) {
1379
+ blocks.push({ type: "text", text: `\n[附件: ${a.name}]` });
1380
+ }
1381
+ }
1382
+ return blocks;
1383
+ }
1384
+ decodeText(buf) {
1385
+ try {
1386
+ return new TextDecoder("utf-8", { fatal: true }).decode(buf);
1387
+ }
1388
+ catch {
1389
+ try {
1390
+ return new TextDecoder("gbk").decode(buf);
1391
+ }
1392
+ catch {
1393
+ return buf.toString("latin1");
1394
+ }
1395
+ }
1396
+ }
1397
+ /** 中止:kill 运行时进程树(所有 conversation 的运行停止)→ 自动重启保持可用。 */
1398
+ async abort() {
1399
+ if (!this.runtime.alive)
1400
+ return;
1401
+ const conv = this.conv;
1402
+ conv.isStreaming = false;
1403
+ conv.streaming = null;
1404
+ // 手动停止 → 清当前会话的 DSH 原生目标(半成品运行不该继续被轮次驱动)。
1405
+ // 旧进程还活着,先 goal/clear 落盘,再重启运行时。
1406
+ if (conv.dsGoal || conv.goal.goal) {
1407
+ try {
1408
+ await this.runtime.goalClear(conv.sessionId);
1409
+ }
1410
+ catch {
1411
+ /* 进程可能已死,事件兜底 */
1412
+ }
1413
+ conv.dsGoal = null;
1414
+ conv.goal.goal = null;
1415
+ conv.goal.conversationId = null;
1416
+ conv.goal.reviewing = false;
1417
+ conv.goal.verdict = "pending";
1418
+ conv.goal.feedback = undefined;
1419
+ conv.goal.status = "已手动停止,目标已中止";
1420
+ this.emitGoalStatus();
1421
+ }
1422
+ this.emit({ type: "notice", level: "info", text: "已停止(DSH 中止 = 重启运行时,进行中的其他对话也会停止)" });
1423
+ try {
1424
+ await this.runtime.restart(this.model);
1425
+ }
1426
+ catch (err) {
1427
+ this.emit({ type: "notice", level: "error", text: `中止后重启失败:${err.message}` });
1428
+ }
1429
+ this.flushSnapshot(true);
1430
+ }
1431
+ async abortBash() {
1432
+ // DSH 无 per-tool 取消;bash 工具由运行时管理,超时策略在运行时侧。
1433
+ this.emit({ type: "notice", level: "info", text: "DSH 引擎暂不支持单独中止 bash(可整体停止对话)" });
1434
+ }
1435
+ // -----------------------------------------------------------------------
1436
+ // 后台任务
1437
+ // -----------------------------------------------------------------------
1438
+ async listBgServers() {
1439
+ await this.bg.listAndPush();
1440
+ }
1441
+ refreshBgTasks() {
1442
+ this.bg.push();
1443
+ }
1444
+ async killBackgroundServer(port, taskId) {
1445
+ if (taskId && this.pluginStopBgTask) {
1446
+ const ok = this.pluginStopBgTask(taskId);
1447
+ if (ok)
1448
+ this.bg.push();
1449
+ return ok;
1450
+ }
1451
+ if (port === undefined)
1452
+ return false;
1453
+ const killed = await this.bg.killOne(port);
1454
+ if (killed)
1455
+ this.bg.push();
1456
+ return killed;
1457
+ }
1458
+ async killAllBackgroundServers() {
1459
+ const killed = await this.bg.killAll();
1460
+ this.bg.push();
1461
+ return killed;
1462
+ }
1463
+ // -----------------------------------------------------------------------
1464
+ // 会话列表 / 切换 / 删除
1465
+ // -----------------------------------------------------------------------
1466
+ sessionsTimer = null;
1467
+ scheduleSessionsRefresh() {
1468
+ if (this.sessionsTimer)
1469
+ return;
1470
+ this.sessionsTimer = setTimeout(() => {
1471
+ this.sessionsTimer = null;
1472
+ if (this.disposed)
1473
+ return;
1474
+ this.emitConversations();
1475
+ void this.pushSessions();
1476
+ }, 800);
1477
+ }
1478
+ async refreshSessions() {
1479
+ await this.pushSessions();
1480
+ }
1481
+ async pushSessions() {
1482
+ const files = findSessionFilesForCwd(this.sessionRoot, this.cwd);
1483
+ const summaries = [];
1484
+ for (const file of files) {
1485
+ try {
1486
+ const sessionId = basename(dirname(file));
1487
+ // 审查会话(review-*)是内部工作会话,不进历史列表。
1488
+ if (sessionId.startsWith("review-"))
1489
+ continue;
1490
+ const { events } = readSessionLog(file);
1491
+ summaries.push({
1492
+ path: file,
1493
+ name: sessionId,
1494
+ firstMessage: firstUserText(events),
1495
+ messageCount: events.filter((e) => e.type === "user/message" || e.type === "assistant/message" || e.type === "tool/result").length,
1496
+ modified: statSync(file).mtimeMs,
1497
+ source: "web",
1498
+ });
1499
+ }
1500
+ catch {
1501
+ /* skip unreadable */
1502
+ }
1503
+ }
1504
+ this.emit({ type: "sessions", sessions: summaries });
1505
+ }
1506
+ async deleteSession(path) {
1507
+ try {
1508
+ const { rmSync } = await import("node:fs");
1509
+ const abs = resolve(path);
1510
+ if (!abs.startsWith(this.sessionRoot + sep)) {
1511
+ this.emit({ type: "notice", level: "error", text: "拒绝删除会话目录之外的路径" });
1512
+ return;
1513
+ }
1514
+ rmSync(abs, { recursive: true, force: true });
1515
+ await this.pushSessions();
1516
+ this.emit({ type: "notice", level: "info", text: "会话已删除" });
1517
+ }
1518
+ catch (err) {
1519
+ this.emit({ type: "notice", level: "error", text: `删除失败:${err.message}` });
1520
+ }
1521
+ }
1522
+ /** 切换会话:读 JSONL 回放 → 新建 conversation(同一 sessionId 续聊)。 */
1523
+ async switchSession(path) {
1524
+ try {
1525
+ const abs = resolve(path);
1526
+ const sessionId = basename(dirname(abs));
1527
+ // 同一 sessionId 已在运行 → 直接切过去。
1528
+ for (const conv of this.convs.values()) {
1529
+ if (conv.sessionId === sessionId) {
1530
+ await this.switchConversation(conv.id);
1531
+ return;
1532
+ }
1533
+ }
1534
+ const prev = this.conv;
1535
+ prev.listed = prev.isStreaming || prev.terminals.list().length > 0 || prev.promptedSinceActive;
1536
+ const conv = this.addConversation(sessionId, this.cwd, true);
1537
+ conv.fromDisk = true; // 磁盘回放 → prompt 时 fork
1538
+ this.activeId = conv.id;
1539
+ this.emitConversations();
1540
+ this.pushTerminals();
1541
+ this.flushSnapshot(true);
1542
+ }
1543
+ catch (err) {
1544
+ this.emit({ type: "notice", level: "error", text: `切换会话失败:${err.message}` });
1545
+ }
1546
+ }
1547
+ // -----------------------------------------------------------------------
1548
+ // 用户 patch 扩展缝(<dataDir>/dsh-patches/*.yml)
1549
+ // -----------------------------------------------------------------------
1550
+ /** 用户 patch 目录。 */
1551
+ userPatchDir() {
1552
+ return join(this.dataDir, "dsh-patches");
1553
+ }
1554
+ /** 列出 <dataDir>/dsh-patches/*.yml(按文件名序),带文件信息。 */
1555
+ async listDshPatches() {
1556
+ const dir = this.userPatchDir();
1557
+ const files = [];
1558
+ try {
1559
+ const { readdirSync } = await import("node:fs");
1560
+ for (const name of readdirSync(dir)) {
1561
+ if (!/^\./u.test(name) && /\.ya?ml$/iu.test(name)) {
1562
+ try {
1563
+ const st = statSync(join(dir, name));
1564
+ if (st.isFile()) {
1565
+ files.push({ name, path: join(dir, name), size: st.size, mtimeMs: st.mtimeMs });
1566
+ }
1567
+ }
1568
+ catch {
1569
+ /* skip unreadable */
1570
+ }
1571
+ }
1572
+ }
1573
+ }
1574
+ catch {
1575
+ /* 目录不存在 = 无用户 patch */
1576
+ }
1577
+ files.sort((a, b) => a.name.localeCompare(b.name));
1578
+ this.emit({ type: "dsh_patches", patchDir: dir, files });
1579
+ }
1580
+ /** 重扫用户 patch:重启运行时使新 patch 生效(patch 只在 boot 时加载)。 */
1581
+ async rescanDshPatches() {
1582
+ try {
1583
+ if (this.runtime.alive) {
1584
+ await this.runtime.restart(this.model);
1585
+ }
1586
+ this.emit({ type: "notice", level: "info", text: "已重扫用户 patch 并重启运行时" });
1587
+ }
1588
+ catch (err) {
1589
+ this.emit({ type: "notice", level: "error", text: `重扫用户 patch 失败:${err.message}` });
1590
+ }
1591
+ await this.listDshPatches();
1592
+ }
1593
+ // -----------------------------------------------------------------------
1594
+ // 项目 / 文件 / SCM / 搜索
1595
+ // -----------------------------------------------------------------------
1596
+ async pushProjects() {
1597
+ const saved = this.stateStore.get(this.clientId);
1598
+ const projects = new Map();
1599
+ for (const p of saved.projects ?? []) {
1600
+ if (!(saved.removedProjects ?? []).includes(p.path)) {
1601
+ projects.set(p.path, p.lastUsed);
1602
+ }
1603
+ }
1604
+ // 合并当前会话目录发现的项目。
1605
+ const cwdProjects = new Set();
1606
+ try {
1607
+ const { readdirSync } = await import("node:fs");
1608
+ const entries = readdirSync(this.sessionRoot, { withFileTypes: true });
1609
+ for (const e of entries) {
1610
+ if (e.isDirectory()) {
1611
+ const cwd = this.decodeProjectKey(e.name);
1612
+ if (cwd && !projects.has(cwd))
1613
+ cwdProjects.add(cwd);
1614
+ }
1615
+ }
1616
+ }
1617
+ catch {
1618
+ /* best effort */
1619
+ }
1620
+ for (const cwd of cwdProjects)
1621
+ projects.set(cwd, Date.now());
1622
+ projects.set(this.cwd, Date.now());
1623
+ const list = [...projects.entries()]
1624
+ .map(([path, lastUsed]) => ({ path, lastUsed }))
1625
+ .sort((a, b) => b.lastUsed - a.lastUsed)
1626
+ .slice(0, 30);
1627
+ this.emit({ type: "projects", projects: list });
1628
+ }
1629
+ decodeProjectKey(key) {
1630
+ // --<cwd>-- → 反解(尽力)。
1631
+ if (!key.startsWith("--") || !key.endsWith("--"))
1632
+ return null;
1633
+ const inner = key.slice(2, -2);
1634
+ let out = "";
1635
+ for (let i = 0; i < inner.length; i++) {
1636
+ if (inner[i] === "-") {
1637
+ out += "/";
1638
+ }
1639
+ else if (inner[i] === "~" && i + 4 < inner.length) {
1640
+ const hex = inner.slice(i + 1, i + 5);
1641
+ if (/^[0-9A-Fa-f]{4}$/.test(hex)) {
1642
+ out += String.fromCharCode(parseInt(hex, 16));
1643
+ i += 4;
1644
+ }
1645
+ else {
1646
+ out += "~";
1647
+ }
1648
+ }
1649
+ else {
1650
+ out += inner[i];
1651
+ }
1652
+ }
1653
+ return out || null;
1654
+ }
1655
+ async removeProject(path) {
1656
+ this.stateStore.removeProject(this.clientId, path);
1657
+ await this.pushProjects();
1658
+ }
1659
+ async listFiles(relPath) {
1660
+ await this.files.listFiles(relPath);
1661
+ }
1662
+ async searchFiles(query, reqId) {
1663
+ await this.files.searchFiles(query, reqId);
1664
+ }
1665
+ async searchSessions(query, reqId) {
1666
+ const q = query.trim().toLowerCase();
1667
+ if (!q) {
1668
+ this.emit({ type: "session_search_results", reqId, query, ok: true, results: [] });
1669
+ return;
1670
+ }
1671
+ try {
1672
+ const { readFileSync: readSessionFile } = await import("node:fs");
1673
+ const files = findSessionFilesForCwd(this.sessionRoot, this.cwd);
1674
+ const results = [];
1675
+ for (const file of files) {
1676
+ try {
1677
+ const { events } = readSessionLog(file);
1678
+ const texts = [];
1679
+ const anchors = [];
1680
+ for (const ev of events) {
1681
+ // P1-14:索引范围 = user/assistant 文本 + tool-result 工具输出。
1682
+ if (ev.type === "user/message" || ev.type === "assistant/message" || ev.type === "tool/result") {
1683
+ const blocks = ev.data?.message?.content ??
1684
+ ev.data?.content ??
1685
+ [];
1686
+ let text = blocks
1687
+ .map((b) => (b?.type === "text" ? b.text ?? "" : ""))
1688
+ .join("\n");
1689
+ if (ev.type === "tool/result") {
1690
+ // 工具输出在嵌套 content[](tool-result 块内),一并纳入索引。
1691
+ const nested = blocks
1692
+ .filter((b) => b?.type === "tool-result")
1693
+ .flatMap((b) => b.content ?? [])
1694
+ .map((b) => (b?.type === "text" ? b.text ?? "" : ""))
1695
+ .join("\n");
1696
+ if (nested)
1697
+ text = `${text}\n${nested}`;
1698
+ }
1699
+ if (text) {
1700
+ texts.push(text);
1701
+ if (text.toLowerCase().includes(q)) {
1702
+ const evData = ev.data;
1703
+ const role = evData?.message?.role === "assistant" ? "assistant" : "user";
1704
+ anchors.push({ role, timestamp: ev.time });
1705
+ }
1706
+ }
1707
+ }
1708
+ }
1709
+ const all = texts.join("\n").toLowerCase();
1710
+ const sessionId = basename(dirname(file));
1711
+ // 审查会话不进搜索。
1712
+ if (sessionId.startsWith("review-"))
1713
+ continue;
1714
+ if (all.includes(q) ||
1715
+ sessionId.toLowerCase().includes(q) ||
1716
+ firstUserText(events).toLowerCase().includes(q)) {
1717
+ results.push({
1718
+ path: file,
1719
+ name: sessionId,
1720
+ firstMessage: firstUserText(events),
1721
+ messageCount: events.filter((e) => e.type === "user/message" || e.type === "assistant/message" || e.type === "tool/result").length,
1722
+ modified: statSync(file).mtimeMs,
1723
+ source: "web",
1724
+ anchors: anchors.slice(0, 10),
1725
+ });
1726
+ }
1727
+ }
1728
+ catch {
1729
+ /* skip unreadable */
1730
+ }
1731
+ }
1732
+ results.sort((a, b) => b.modified - a.modified);
1733
+ this.emit({ type: "session_search_results", reqId, query, ok: true, results: results.slice(0, 50) });
1734
+ }
1735
+ catch {
1736
+ this.emit({ type: "session_search_results", reqId, query, ok: false, results: [] });
1737
+ }
1738
+ }
1739
+ async scmQuery(kind, reqId, opts) {
1740
+ // 走 FilesService:只读 git 查询 + git-dir watcher(外部提交 → scm_changed
1741
+ // 面板自动刷新)+ 越界/notRepo 统一处理。
1742
+ await this.files.scmQuery(kind, reqId, opts);
1743
+ }
1744
+ async readFile(relPath) {
1745
+ await this.files.readFile(relPath);
1746
+ }
1747
+ async writeFile(relPath, text) {
1748
+ await this.files.writeFile(relPath, text);
1749
+ }
1750
+ async uploadFile(dirRel, name, data) {
1751
+ await this.files.uploadFile(dirRel, name, data);
1752
+ }
1753
+ async completePath(input) {
1754
+ await this.files.completePath(input);
1755
+ }
1756
+ // -----------------------------------------------------------------------
1757
+ // 模型 / 思考
1758
+ // -----------------------------------------------------------------------
1759
+ /** P2-17 动态模型 id 缓存(adapter 目录查询结果;setModel 校验用)。 */
1760
+ dynamicModels = new Set();
1761
+ async listModels() {
1762
+ // 以本地表为底(定价/上下文窗口/vision 标记),运行时 adapter 目录动态
1763
+ // 扩展——新模型自动出现在选择器,无需改代码。
1764
+ const known = new Map(DSH_MODELS.map((m) => [m.id, m]));
1765
+ let dynamic = [];
1766
+ try {
1767
+ const res = await this.runtime.listModels();
1768
+ dynamic = res.models ?? [];
1769
+ }
1770
+ catch {
1771
+ /* 运行时不可用 → 只发本地表 */
1772
+ }
1773
+ const seen = new Set();
1774
+ const models = [];
1775
+ this.dynamicModels.clear();
1776
+ for (const m of dynamic) {
1777
+ if (seen.has(m.id))
1778
+ continue;
1779
+ seen.add(m.id);
1780
+ const local = known.get(m.id);
1781
+ const vision = local?.vision ??
1782
+ (m.inputModalities?.includes("image") ?? false);
1783
+ if (!local)
1784
+ this.dynamicModels.add(m.id);
1785
+ models.push({
1786
+ id: m.id,
1787
+ name: local?.name ?? m.name ?? m.id,
1788
+ provider: "deepseek",
1789
+ reasoning: true,
1790
+ vision,
1791
+ });
1792
+ }
1793
+ for (const m of DSH_MODELS) {
1794
+ if (seen.has(m.id))
1795
+ continue;
1796
+ seen.add(m.id);
1797
+ models.push({
1798
+ id: m.id,
1799
+ name: m.name,
1800
+ provider: m.provider,
1801
+ reasoning: true,
1802
+ vision: m.vision,
1803
+ });
1804
+ }
1805
+ this.emit({ type: "models", models });
1806
+ }
1807
+ /** 换模型 = 重启运行时。 */
1808
+ async setModel(modelId) {
1809
+ // 校验:本地表 + 运行时动态目录(P2-17)。
1810
+ const known = DSH_MODELS.some((m) => m.id === modelId) || this.dynamicModels.has(modelId);
1811
+ if (!known) {
1812
+ this.emit({ type: "notice", level: "warning", text: `未知模型:${modelId}` });
1813
+ return;
1814
+ }
1815
+ if (modelId === this.model)
1816
+ return;
1817
+ // P0-2 竞态告知:换模型 = 强杀运行时 → 进行中的 run 全部中止。
1818
+ if ([...this.convs.values()].some((c) => c.isStreaming)) {
1819
+ this.emit({
1820
+ type: "notice",
1821
+ level: "warning",
1822
+ text: "有对话正在运行,切换模型将中止当前所有运行(DSH 换模型 = 重启运行时)",
1823
+ });
1824
+ }
1825
+ this.model = modelId;
1826
+ try {
1827
+ await this.runtime.restart(modelId);
1828
+ this.emit({ type: "notice", level: "info", text: `已切换到 ${modelId}` });
1829
+ }
1830
+ catch (err) {
1831
+ this.emit({ type: "notice", level: "error", text: `切换模型失败:${err.message}` });
1832
+ }
1833
+ this.flushSnapshot(true);
1834
+ }
1835
+ async cycleModel() {
1836
+ const idx = DSH_MODELS.findIndex((m) => m.id === this.model);
1837
+ const next = DSH_MODELS[(idx + 1) % DSH_MODELS.length];
1838
+ await this.setModel(next.id);
1839
+ }
1840
+ setThinking(level) {
1841
+ if (level !== "high") {
1842
+ this.emit({ type: "notice", level: "info", text: "DeepSeek V4 仅支持高思考强度" });
1843
+ return;
1844
+ }
1845
+ this.thinkingLevel = level;
1846
+ this.flushSnapshot();
1847
+ }
1848
+ cycleThinking() {
1849
+ // DSH 固定 high。
1850
+ this.emit({ type: "notice", level: "info", text: "DeepSeek V4 仅支持高思考强度" });
1851
+ }
1852
+ // -----------------------------------------------------------------------
1853
+ // 设置 / 系统提示词
1854
+ // -----------------------------------------------------------------------
1855
+ pushSettings() {
1856
+ const settings = {
1857
+ promptMode: this.settings.promptMode,
1858
+ customSystemPrompt: this.settings.customSystemPrompt,
1859
+ disabledSkills: this.settings.disabledSkills,
1860
+ disabledExtensions: this.settings.disabledExtensions,
1861
+ terminalToolsEnabled: this.settings.terminalToolsEnabled,
1862
+ terminalBash: this.settings.terminalBash,
1863
+ terminalBashIdleMs: this.settings.terminalBashIdleMs,
1864
+ thinkingWrap: this.settings.thinkingWrap,
1865
+ toolsWrap: this.settings.toolsWrap,
1866
+ visionBridgeEnabled: false,
1867
+ visionBridgeModel: null,
1868
+ visionBridgePromptMode: "append",
1869
+ visionBridgePrompt: "",
1870
+ reviewPrompt: this.settings.reviewPrompt,
1871
+ reviewDisabledSkills: [],
1872
+ disabledPlugins: this.settings.disabledPlugins,
1873
+ defaultSystemPrompt: "",
1874
+ effectiveSystemPrompt: this.settings.customSystemPrompt,
1875
+ visionBridgeDefaultPrompt: "",
1876
+ visionModels: [],
1877
+ skills: this.skillsCache,
1878
+ reviewSkills: [],
1879
+ extensions: [],
1880
+ presets: this.stateStore.getPresets(this.clientId),
1881
+ };
1882
+ this.emit({ type: "settings_state", settings });
1883
+ }
1884
+ async setSettings(partial) {
1885
+ if (partial.promptMode !== undefined)
1886
+ this.settings.promptMode = partial.promptMode;
1887
+ if (partial.customSystemPrompt !== undefined)
1888
+ this.settings.customSystemPrompt = partial.customSystemPrompt;
1889
+ if (partial.disabledSkills !== undefined)
1890
+ this.settings.disabledSkills = partial.disabledSkills;
1891
+ if (partial.disabledExtensions !== undefined)
1892
+ this.settings.disabledExtensions = partial.disabledExtensions;
1893
+ if (partial.terminalToolsEnabled !== undefined)
1894
+ this.settings.terminalToolsEnabled = partial.terminalToolsEnabled;
1895
+ if (partial.terminalBash !== undefined)
1896
+ this.settings.terminalBash = partial.terminalBash;
1897
+ if (partial.terminalBashIdleMs !== undefined)
1898
+ this.settings.terminalBashIdleMs = partial.terminalBashIdleMs;
1899
+ if (partial.thinkingWrap !== undefined)
1900
+ this.settings.thinkingWrap = partial.thinkingWrap;
1901
+ if (partial.toolsWrap !== undefined)
1902
+ this.settings.toolsWrap = partial.toolsWrap;
1903
+ if (partial.disabledPlugins !== undefined)
1904
+ this.settings.disabledPlugins = partial.disabledPlugins;
1905
+ if (partial.reviewPrompt !== undefined)
1906
+ this.settings.reviewPrompt = partial.reviewPrompt;
1907
+ // 持久化(跨重连存活)。
1908
+ this.stateStore.saveSettings(this.clientId, {
1909
+ promptMode: this.settings.promptMode,
1910
+ customSystemPrompt: this.settings.customSystemPrompt,
1911
+ disabledSkills: this.settings.disabledSkills,
1912
+ disabledExtensions: this.settings.disabledExtensions,
1913
+ terminalToolsEnabled: this.settings.terminalToolsEnabled,
1914
+ terminalBash: this.settings.terminalBash,
1915
+ terminalBashIdleMs: this.settings.terminalBashIdleMs,
1916
+ thinkingWrap: this.settings.thinkingWrap,
1917
+ toolsWrap: this.settings.toolsWrap,
1918
+ disabledPlugins: this.settings.disabledPlugins,
1919
+ reviewPrompt: this.settings.reviewPrompt,
1920
+ });
1921
+ // 仅系统提示词变化才重启运行时(DSH_PERSONA 由 launcher env 注入);
1922
+ // 其他设置(开关/隐藏插件等)只存不回写运行时。
1923
+ const personaChanged = partial.promptMode !== undefined || partial.customSystemPrompt !== undefined;
1924
+ if (personaChanged)
1925
+ await this.applyPersona();
1926
+ // 技能启停(#18):禁用集变化 → 同步运行时(晚 pre-step 钩子过滤目录)并刷新技能列表。
1927
+ if (partial.disabledSkills !== undefined) {
1928
+ void this.pushDisabledSkillsToRuntime();
1929
+ void this.refreshSkillsFromRuntime();
1930
+ }
1931
+ this.pushSettings();
1932
+ this.flushSnapshot();
1933
+ }
1934
+ applyPersona() {
1935
+ const custom = this.settings.customSystemPrompt.trim();
1936
+ const persona = this.settings.promptMode === "replace" && custom
1937
+ ? custom
1938
+ : this.settings.promptMode === "append" && custom
1939
+ ? `\n\n${custom}`
1940
+ : "";
1941
+ this.runtime.env = { ...this.runtime.env, DSH_PERSONA: persona };
1942
+ if (this.runtime.alive) {
1943
+ return this.runtime.restart(this.model).catch(() => {
1944
+ /* keep old runtime */
1945
+ });
1946
+ }
1947
+ return Promise.resolve();
1948
+ }
1949
+ async reloadExtensions() {
1950
+ // DSH 引擎无 pi 扩展体系。
1951
+ this.emit({ type: "notice", level: "info", text: "DSH 引擎不支持 pi 扩展热重载" });
1952
+ }
1953
+ async savePreset(name) {
1954
+ const presets = this.stateStore.getPresets(this.clientId);
1955
+ const existing = presets.find((p) => p.name === name);
1956
+ const preset = {
1957
+ name,
1958
+ promptMode: this.settings.promptMode,
1959
+ customSystemPrompt: this.settings.customSystemPrompt,
1960
+ disabledSkills: this.settings.disabledSkills,
1961
+ disabledExtensions: this.settings.disabledExtensions,
1962
+ terminalToolsEnabled: this.settings.terminalToolsEnabled,
1963
+ terminalBash: this.settings.terminalBash,
1964
+ terminalBashIdleMs: this.settings.terminalBashIdleMs,
1965
+ visionBridgePromptMode: "append",
1966
+ visionBridgePrompt: "",
1967
+ reviewPrompt: this.settings.reviewPrompt,
1968
+ reviewDisabledSkills: [],
1969
+ };
1970
+ this.stateStore.savePresets(this.clientId, existing ? presets.map((p) => (p.name === name ? preset : p)) : [...presets, preset]);
1971
+ this.emit({ type: "notice", level: "info", text: `预设「${name}」已保存` });
1972
+ this.pushSettings();
1973
+ }
1974
+ async applyPreset(name) {
1975
+ const preset = this.stateStore.getPresets(this.clientId).find((p) => p.name === name);
1976
+ if (!preset) {
1977
+ this.emit({ type: "notice", level: "warning", text: `预设「${name}」不存在` });
1978
+ return;
1979
+ }
1980
+ this.settings.promptMode = preset.promptMode;
1981
+ this.settings.customSystemPrompt = preset.customSystemPrompt;
1982
+ this.settings.disabledSkills = preset.disabledSkills ?? [];
1983
+ this.settings.disabledExtensions = preset.disabledExtensions ?? [];
1984
+ this.settings.terminalToolsEnabled = preset.terminalToolsEnabled;
1985
+ this.settings.terminalBash = preset.terminalBash;
1986
+ this.settings.terminalBashIdleMs = preset.terminalBashIdleMs;
1987
+ this.settings.reviewPrompt = preset.reviewPrompt ?? "";
1988
+ this.stateStore.saveSettings(this.clientId, {
1989
+ promptMode: this.settings.promptMode,
1990
+ customSystemPrompt: this.settings.customSystemPrompt,
1991
+ disabledSkills: this.settings.disabledSkills,
1992
+ disabledExtensions: this.settings.disabledExtensions,
1993
+ terminalToolsEnabled: this.settings.terminalToolsEnabled,
1994
+ terminalBash: this.settings.terminalBash,
1995
+ terminalBashIdleMs: this.settings.terminalBashIdleMs,
1996
+ thinkingWrap: this.settings.thinkingWrap,
1997
+ toolsWrap: this.settings.toolsWrap,
1998
+ disabledPlugins: this.settings.disabledPlugins,
1999
+ reviewPrompt: this.settings.reviewPrompt,
2000
+ });
2001
+ await this.applyPersona();
2002
+ this.pushSettings();
2003
+ this.flushSnapshot();
2004
+ }
2005
+ async deletePreset(name) {
2006
+ const presets = this.stateStore.getPresets(this.clientId);
2007
+ this.stateStore.savePresets(this.clientId, presets.filter((p) => p.name !== name));
2008
+ this.pushSettings();
2009
+ }
2010
+ // -----------------------------------------------------------------------
2011
+ // 目标(DSH 原生 goal 域:goal/change 事件驱动,round-driver 自动轮次)
2012
+ // -----------------------------------------------------------------------
2013
+ emitGoalStatus() {
2014
+ this.emit({ type: "goal_status", status: { ...this.conv.goal } });
2015
+ }
2016
+ /**
2017
+ * goal/change 事件(DSH 原生状态机全量快照)→ 镜像到当前 conversation。
2018
+ * 权威源在运行时:create/edit/resume → active;complete → pass;block → fail。
2019
+ */
2020
+ applyGoalChange(conv, data) {
2021
+ const g = conv.goal;
2022
+ if (data.operation === "clear" || (!data.goal && data.cleared)) {
2023
+ conv.dsGoal = null;
2024
+ g.goal = null;
2025
+ g.conversationId = null;
2026
+ g.reviewing = false;
2027
+ g.verdict = "pending";
2028
+ g.feedback = undefined;
2029
+ g.status = "";
2030
+ }
2031
+ else if (data.goal?.objective) {
2032
+ conv.dsGoal = {
2033
+ id: data.goal.id ?? "",
2034
+ revision: data.goal.revision ?? 0,
2035
+ phase: data.goal.phase ?? "active",
2036
+ objective: data.goal.objective,
2037
+ maxGoalRounds: data.goal.maxGoalRounds ?? 0,
2038
+ roundsStarted: data.roundsStarted ?? 0,
2039
+ ...(data.goal.blockedReason ? { blockedReason: data.goal.blockedReason } : {}),
2040
+ };
2041
+ g.goal = data.goal.objective;
2042
+ g.conversationId = conv.id;
2043
+ g.round = data.roundsStarted ?? 0;
2044
+ if (data.goal.maxGoalRounds)
2045
+ g.maxRounds = data.goal.maxGoalRounds;
2046
+ const phase = data.goal.phase ?? "active";
2047
+ if (phase === "active") {
2048
+ g.reviewing = true;
2049
+ g.verdict = "pending";
2050
+ g.feedback = undefined;
2051
+ // P1-10:轮次已达上限且仍未完成 → 提示轮尽(模型可能正在跑最后一轮,
2052
+ // 后续 complete/blocked 事件会覆盖此状态)。
2053
+ const rounds = g.round ?? 0;
2054
+ const max = data.goal.maxGoalRounds ?? g.maxRounds ?? 0;
2055
+ g.status =
2056
+ max > 0 && rounds >= max
2057
+ ? `已达轮数上限(${rounds}/${max}),目标未完成`
2058
+ : `目标进行中(第 ${rounds + 1} 轮)…`;
2059
+ }
2060
+ else if (phase === "complete") {
2061
+ g.reviewing = false;
2062
+ g.verdict = "pass";
2063
+ g.status = "✅ 目标已达成";
2064
+ }
2065
+ else if (phase === "blocked") {
2066
+ g.reviewing = false;
2067
+ g.verdict = "fail";
2068
+ g.feedback = data.goal.blockedReason ?? "(模型报告受阻)";
2069
+ g.status = "目标受阻";
2070
+ }
2071
+ else if (phase === "paused") {
2072
+ g.reviewing = false;
2073
+ g.verdict = "pending";
2074
+ g.status = "目标已暂停";
2075
+ }
2076
+ else {
2077
+ g.reviewing = false;
2078
+ }
2079
+ }
2080
+ if (conv.id === this.activeId) {
2081
+ this.emitGoalStatus();
2082
+ this.flushSnapshot();
2083
+ }
2084
+ }
2085
+ async setGoal(goal, opts) {
2086
+ if (goal.trim() === "") {
2087
+ await this.clearGoal();
2088
+ return;
2089
+ }
2090
+ if (this.quiesceBlocked())
2091
+ return;
2092
+ const conv = this.conv;
2093
+ const text = goal.trim();
2094
+ const g = conv.goal;
2095
+ g.goal = text;
2096
+ g.conversationId = conv.id;
2097
+ g.reviewModel = opts?.reviewModel ?? g.reviewModel;
2098
+ g.maxRounds = opts?.maxRounds ?? g.maxRounds;
2099
+ g.locked = opts?.locked ?? g.locked;
2100
+ // P1-11:DSH 无独立审查者,locked 映射为轮次语义 ——
2101
+ // locked=true(目标锁定,必须达成)→ 保留用户轮次上限;
2102
+ // locked=false(不锁定)→ 单轮语义近似(maxGoalRounds=1,一轮后结束)。
2103
+ const effectiveMax = g.locked ? g.maxRounds : 1;
2104
+ g.round = 0;
2105
+ g.reviewing = true; // round-driver 会立刻续第一轮
2106
+ g.verdict = "pending";
2107
+ g.feedback = undefined;
2108
+ g.status = "目标已设,等待生成…";
2109
+ this.goalPrefs = { reviewModel: g.reviewModel, maxRounds: g.maxRounds, locked: g.locked };
2110
+ this.stateStore.saveGoalPrefs(this.clientId, {
2111
+ reviewModel: g.reviewModel,
2112
+ maxRounds: g.maxRounds,
2113
+ locked: g.locked,
2114
+ });
2115
+ this.emitGoalStatus();
2116
+ this.emit({ type: "notice", level: "info", text: `🎯 已设目标:${text.slice(0, 80)}${text.length > 80 ? "…" : ""}` });
2117
+ try {
2118
+ // goal/set 创建 + arm;round-driver 在 agent idle 时自动续轮,无需额外 prompt。
2119
+ const res = (await this.runtime.goalSet(conv.sessionId, text, effectiveMax > 0 ? effectiveMax : undefined));
2120
+ if (res?.goal?.maxGoalRounds)
2121
+ g.maxRounds = res.goal.maxGoalRounds;
2122
+ }
2123
+ catch (err) {
2124
+ g.reviewing = false;
2125
+ g.status = `目标设置失败:${err.message}`;
2126
+ this.emit({ type: "notice", level: "error", text: `目标设置失败:${err.message}` });
2127
+ }
2128
+ this.emitGoalStatus();
2129
+ this.flushSnapshot();
2130
+ }
2131
+ async clearGoal() {
2132
+ const conv = this.conv;
2133
+ // 向导进行中 → 中断等待(模型侧提问会因超时/无 provider 恢复)。
2134
+ if (conv.turnWaiter) {
2135
+ const w = conv.turnWaiter;
2136
+ conv.turnWaiter = undefined;
2137
+ w.reject(new Error("调研已取消"));
2138
+ }
2139
+ if (conv.dsGoal) {
2140
+ try {
2141
+ await this.runtime.goalClear(conv.sessionId);
2142
+ }
2143
+ catch {
2144
+ /* goal/change clear 事件会回来兜底 */
2145
+ }
2146
+ }
2147
+ conv.dsGoal = null;
2148
+ const g = conv.goal;
2149
+ g.goal = null;
2150
+ g.conversationId = null;
2151
+ g.reviewing = false;
2152
+ g.verdict = "pending";
2153
+ g.feedback = undefined;
2154
+ g.status = "";
2155
+ this.emitGoalStatus();
2156
+ this.flushSnapshot();
2157
+ }
2158
+ async startGoalWizard(text, opts) {
2159
+ // 交互式调研向导:主会话 prompt 向导指令 → 模型用 ask_user_question 逐题
2160
+ // 提问(经提问桥 → 浏览器对话框)→ 收敛输出 GOAL: 行 → 自动设目标。
2161
+ if (this.quiesceBlocked())
2162
+ return;
2163
+ const conv = this.conv;
2164
+ const draft = (text ?? "").trim();
2165
+ if (!draft)
2166
+ return;
2167
+ const g = conv.goal;
2168
+ if (g.goal || g.reviewing || g.wizard.active) {
2169
+ this.emit({ type: "notice", level: "warning", text: "已有目标或调研进行中,请先完成或清除" });
2170
+ return;
2171
+ }
2172
+ g.wizard.active = true;
2173
+ g.wizard.draft = draft;
2174
+ g.wizard.model = opts?.wizardModel ?? null;
2175
+ g.wizard.step = 0;
2176
+ g.wizard.maxSteps = 6;
2177
+ g.wizard.status = "调研中…";
2178
+ g.status = "目标调研中…";
2179
+ this.emitGoalStatus();
2180
+ this.emit({
2181
+ type: "notice",
2182
+ level: "info",
2183
+ text: `🔍 正在围绕需求展开调研:${draft.slice(0, 60)}${draft.length > 60 ? "…" : ""}`,
2184
+ });
2185
+ try {
2186
+ const waiter = new Promise((resolve, reject) => {
2187
+ const timer = setTimeout(() => reject(new Error("调研超时(10 分钟)")), 10 * 60_000);
2188
+ timer.unref?.();
2189
+ conv.turnWaiter = {
2190
+ resolve: () => {
2191
+ clearTimeout(timer);
2192
+ resolve();
2193
+ },
2194
+ reject: (err) => {
2195
+ clearTimeout(timer);
2196
+ reject(err);
2197
+ },
2198
+ };
2199
+ });
2200
+ await this.promptConv(conv, this.wizardPrompt(draft));
2201
+ await waiter;
2202
+ }
2203
+ catch (err) {
2204
+ this.emit({ type: "notice", level: "warning", text: `目标调研中断:${err.message}` });
2205
+ }
2206
+ conv.turnWaiter = undefined;
2207
+ g.wizard.active = false;
2208
+ g.wizard.step = 0;
2209
+ g.wizard.status = "";
2210
+ // 解析模型最终输出中的 GOAL: 行。
2211
+ const finalText = this.lastAssistantText(conv);
2212
+ const goalMatch = finalText.match(/GOAL\s*[::]\s*([\s\S]*)/i);
2213
+ let refined = goalMatch ? goalMatch[1].trim() : "";
2214
+ if (!refined) {
2215
+ // 未按格式:取最后一段非空文本(截断防污染)。
2216
+ refined = finalText.split("\n").filter((l) => l.trim()).pop()?.trim() ?? "";
2217
+ if (refined.length > 300)
2218
+ refined = refined.slice(0, 300);
2219
+ }
2220
+ if (!refined && g.goal) {
2221
+ // 模型可能直接用了 create_goal —— 目标已在运行时,镜像它即可。
2222
+ this.emitGoalStatus();
2223
+ this.emit({ type: "notice", level: "info", text: "🎯 调研完成,目标已由模型创建" });
2224
+ return;
2225
+ }
2226
+ this.emitGoalStatus();
2227
+ if (refined) {
2228
+ await this.setGoal(refined, {
2229
+ reviewModel: opts?.wizardModel,
2230
+ maxRounds: opts?.maxRounds,
2231
+ locked: opts?.locked,
2232
+ });
2233
+ this.emit({
2234
+ type: "notice",
2235
+ level: "info",
2236
+ text: `🎯 调研完成,目标已设为:${refined.slice(0, 80)}${refined.length > 80 ? "…" : ""}`,
2237
+ });
2238
+ }
2239
+ else {
2240
+ this.emit({ type: "notice", level: "warning", text: "调研未产出有效目标,请重试" });
2241
+ }
2242
+ }
2243
+ /** 调研向导指令(主会话 prompt):先提问收敛,最后只输出 GOAL: 行。 */
2244
+ wizardPrompt(draft) {
2245
+ return [
2246
+ `You are a goal-clarification wizard. The user stated a raw requirement. Your job is to turn it into ONE precise, actionable goal that a coding agent can fully satisfy.`,
2247
+ ``,
2248
+ `# User's raw requirement`,
2249
+ draft,
2250
+ ``,
2251
+ `Use the ask_user_question tool to ask the user focused questions to pin down the essential, ambiguous details. Ask ONE question at a time, usually 2 to 4 questions total: what exactly to build/do, scope boundaries (what NOT to do), acceptance criteria / done-definition, and any constraints (style, performance, environment). Prefer multiple-choice questions (options) when you can offer clear choices.`,
2252
+ `Once you have enough to write an unambiguous, reviewable goal, STOP asking and reply with EXACTLY this format and nothing else (no preamble, no bullets):`,
2253
+ `GOAL: <one concrete, verifiable sentence describing the deliverable and its acceptance criteria>`,
2254
+ `Do NOT call create_goal or update_goal — just output the GOAL: line. If the user cancels or stops answering, still produce a sensible best-effort GOAL from what you already know.`,
2255
+ ].join("\n");
2256
+ }
2257
+ /** 会话最后一条 assistant 文本(向导/调试提取用)。 */
2258
+ lastAssistantText(conv) {
2259
+ for (let i = conv.messages.length - 1; i >= 0; i--) {
2260
+ const m = conv.messages[i];
2261
+ if (m.role === "assistant") {
2262
+ return m.content.map((c) => ("text" in c ? c.text : "")).join("");
2263
+ }
2264
+ }
2265
+ return "";
2266
+ }
2267
+ async setGoalPrefs(opts) {
2268
+ const g = this.conv.goal;
2269
+ if (opts?.reviewModel !== undefined)
2270
+ g.reviewModel = opts.reviewModel;
2271
+ if (opts?.maxRounds !== undefined)
2272
+ g.maxRounds = opts.maxRounds;
2273
+ if (opts?.locked !== undefined)
2274
+ g.locked = opts.locked;
2275
+ this.goalPrefs = { reviewModel: g.reviewModel, maxRounds: g.maxRounds, locked: g.locked };
2276
+ this.stateStore.saveGoalPrefs(this.clientId, {
2277
+ reviewModel: g.reviewModel,
2278
+ maxRounds: g.maxRounds,
2279
+ locked: g.locked,
2280
+ });
2281
+ this.emitGoalStatus();
2282
+ }
2283
+ // -----------------------------------------------------------------------
2284
+ // 命令列表(.pi/commands.json → DSH 无命令体系,简化为空/透传存储)
2285
+ // -----------------------------------------------------------------------
2286
+ async listCommands() {
2287
+ const { commands, path } = await loadCommands(this.cwd);
2288
+ this.emit({ type: "commands", commands, path });
2289
+ }
2290
+ async saveCommands(commands) {
2291
+ const { path, error } = await saveCommandsFile(this.cwd, commands);
2292
+ if (error) {
2293
+ this.emit({ type: "notice", level: "error", text: `保存命令失败:${error}` });
2294
+ }
2295
+ else {
2296
+ this.emit({ type: "notice", level: "info", text: `命令已保存(${path})` });
2297
+ }
2298
+ }
2299
+ // -----------------------------------------------------------------------
2300
+ // 斜杠命令(内置 NATIVE + 插件 registerCommand;DSH 无扩展/技能/模板体系)
2301
+ // -----------------------------------------------------------------------
2302
+ async pushSlashCommands() {
2303
+ const commands = [];
2304
+ const seen = new Set();
2305
+ for (const c of NATIVE_COMMANDS) {
2306
+ commands.push({ ...c, source: "builtin" });
2307
+ seen.add(c.name);
2308
+ }
2309
+ for (const cmd of this.pluginCommandsProvider?.() ?? []) {
2310
+ if (seen.has(cmd.name))
2311
+ continue;
2312
+ commands.push({
2313
+ name: cmd.name,
2314
+ description: cmd.description,
2315
+ descriptionEn: cmd.descriptionEn,
2316
+ argumentHint: cmd.argumentHint,
2317
+ argumentHintEn: cmd.argumentHintEn,
2318
+ source: "plugin",
2319
+ });
2320
+ seen.add(cmd.name);
2321
+ }
2322
+ this.emit({ type: "slash_commands", commands });
2323
+ }
2324
+ /** 拦截执行斜杠命令;返回 true 表示已处理(不发给模型)。 */
2325
+ async execSlash(name, args) {
2326
+ switch (name) {
2327
+ case "new":
2328
+ await this.newChat();
2329
+ return true;
2330
+ case "model": {
2331
+ if (!args.trim()) {
2332
+ this.emit({ type: "notice", level: "info", text: `当前模型:${this.model}。用法:/model <名称>` });
2333
+ return true;
2334
+ }
2335
+ const q = args.trim().toLowerCase();
2336
+ const all = [...DSH_MODELS, ...[...this.dynamicModels].map((id) => ({ id, name: id }))];
2337
+ const hit = all.find((m) => m.id === q || m.name?.toLowerCase().includes(q));
2338
+ if (hit) {
2339
+ await this.setModel(hit.id);
2340
+ }
2341
+ else {
2342
+ this.emit({
2343
+ type: "notice",
2344
+ level: "error",
2345
+ text: `没有匹配到模型:${args.trim()}(可用模型见顶栏模型列表)`,
2346
+ });
2347
+ }
2348
+ return true;
2349
+ }
2350
+ case "cwd": {
2351
+ if (!args.trim()) {
2352
+ this.emit({ type: "notice", level: "info", text: `当前工作目录:${this.cwd}` });
2353
+ return true;
2354
+ }
2355
+ await this.setCwd(args.trim());
2356
+ return true;
2357
+ }
2358
+ case "resume":
2359
+ await this.refreshSessions();
2360
+ return true;
2361
+ case "help":
2362
+ return true; // 前端 /help modal 展示目录
2363
+ case "copy":
2364
+ return true; // 前端本地实现
2365
+ case "reload":
2366
+ this.emit({ type: "notice", level: "info", text: "已重新加载(DSH 引擎无扩展/技能热重载,运行时能力内置)" });
2367
+ await this.pushSlashCommands();
2368
+ return true;
2369
+ case "compact":
2370
+ this.emit({ type: "notice", level: "info", text: "DSH 引擎不支持上下文压缩(运行时自动管理)" });
2371
+ return true;
2372
+ case "thinking":
2373
+ this.emit({ type: "notice", level: "info", text: "DeepSeek V4 仅支持高思考强度" });
2374
+ return true;
2375
+ case "pi-web-ui:quit":
2376
+ this.onQuit?.();
2377
+ return true;
2378
+ }
2379
+ // 插件命令(host.registerCommand)
2380
+ const def = this.pluginCommandsProvider?.().find((c) => c.name === name);
2381
+ if (def) {
2382
+ try {
2383
+ const result = await def.run(args, { clientId: this.clientId });
2384
+ if (typeof result === "string" && result.trim()) {
2385
+ this.emit({ type: "notice", level: "info", text: result });
2386
+ }
2387
+ }
2388
+ catch (err) {
2389
+ this.emit({
2390
+ type: "notice",
2391
+ level: "error",
2392
+ text: `插件命令 /${name} 执行失败:${err.message}`,
2393
+ });
2394
+ }
2395
+ return true;
2396
+ }
2397
+ return false;
2398
+ }
2399
+ // -----------------------------------------------------------------------
2400
+ // 自更新
2401
+ // -----------------------------------------------------------------------
2402
+ static currentAppVersion() {
2403
+ try {
2404
+ const pkg = JSON.parse(readFileSync(join(dirname(new URL(import.meta.url).pathname), "..", "package.json"), "utf8"));
2405
+ return pkg.version ?? "0.0.0";
2406
+ }
2407
+ catch {
2408
+ return "0.0.0";
2409
+ }
2410
+ }
2411
+ async checkUpdate() {
2412
+ try {
2413
+ const latest = await checkAllUpdates([{ name: "pi-web-ui", version: DshClientSession.currentAppVersion(), kind: "webui" }]);
2414
+ const item = latest[0];
2415
+ this.emit({
2416
+ type: "update_status",
2417
+ current: item.current,
2418
+ latest: item.latest,
2419
+ latestPublishedAt: item.latestPublishedAt ?? null,
2420
+ upToDate: item.upToDate,
2421
+ error: item.error,
2422
+ });
2423
+ }
2424
+ catch (err) {
2425
+ this.emit({ type: "update_status", current: DshClientSession.currentAppVersion(), latest: null, latestPublishedAt: null, upToDate: true, error: err.message });
2426
+ }
2427
+ }
2428
+ async checkUpdatesAll(force = false) {
2429
+ try {
2430
+ const targets = collectTargets(join(homedir(), ".pi", "agent"), DshClientSession.currentAppVersion());
2431
+ const items = await checkAllUpdates(targets);
2432
+ if (force) {
2433
+ // 强制模式:忽略缓存(默认 Fetcher 带 TTL,直接再查一次即可)。
2434
+ void items;
2435
+ }
2436
+ this.emit({
2437
+ type: "update_status_all",
2438
+ items: items.map((i) => ({
2439
+ name: i.name,
2440
+ kind: i.kind,
2441
+ current: i.current,
2442
+ latest: i.latest,
2443
+ latestPublishedAt: i.latestPublishedAt ?? null,
2444
+ upToDate: i.upToDate,
2445
+ error: i.error,
2446
+ })),
2447
+ });
2448
+ }
2449
+ catch (err) {
2450
+ this.emit({ type: "update_status_all", items: [] });
2451
+ }
2452
+ }
2453
+ // -----------------------------------------------------------------------
2454
+ // pi 专属:DSH 引擎下的简化实现
2455
+ // -----------------------------------------------------------------------
2456
+ async installPiAgent() {
2457
+ this.emit({ type: "install_result", ok: true, detail: "DSH 引擎不需要 pi CLI" });
2458
+ }
2459
+ async setProviderApiKey(provider, apiKey) {
2460
+ const key = apiKey.trim();
2461
+ if (!key) {
2462
+ this.emit({ type: "notice", level: "error", text: "请填写 API 密钥" });
2463
+ return;
2464
+ }
2465
+ try {
2466
+ // 与 pi 引擎同形状:{ <provider>: { type: "api_key", key } }。
2467
+ const authPath = join(this.agentDir, "auth.json");
2468
+ let auth = {};
2469
+ try {
2470
+ auth = JSON.parse(readFileSync(authPath, "utf8"));
2471
+ }
2472
+ catch {
2473
+ /* new file */
2474
+ }
2475
+ auth[provider.trim()] = { type: "api_key", key };
2476
+ mkdirSync(dirname(authPath), { recursive: true });
2477
+ writeFileSync(authPath, JSON.stringify(auth, null, 2) + "\n");
2478
+ this.emit({ type: "notice", level: "info", text: `✅ 已保存 ${provider.trim()} 的 API 密钥` });
2479
+ if (this.runtime.alive)
2480
+ await this.runtime.restart(this.model);
2481
+ this.flushSnapshot();
2482
+ }
2483
+ catch (err) {
2484
+ this.emit({ type: "notice", level: "error", text: `保存 key 失败:${err.message}` });
2485
+ }
2486
+ }
2487
+ async clearProviderApiKey(provider) {
2488
+ const pid = provider.trim();
2489
+ try {
2490
+ const authPath = join(this.agentDir, "auth.json");
2491
+ const auth = JSON.parse(readFileSync(authPath, "utf8"));
2492
+ if (!(pid in auth)) {
2493
+ this.emit({ type: "notice", level: "info", text: `${pid} 没有已保存的密钥` });
2494
+ return;
2495
+ }
2496
+ delete auth[pid];
2497
+ writeFileSync(authPath, JSON.stringify(auth, null, 2) + "\n");
2498
+ this.emit({ type: "notice", level: "info", text: `🗑 已清除 ${pid} 的密钥,该服务商回到未配置状态` });
2499
+ if (this.runtime.alive && pid === "deepseek")
2500
+ await this.runtime.restart(this.model);
2501
+ this.flushSnapshot();
2502
+ }
2503
+ catch (err) {
2504
+ this.emit({ type: "notice", level: "error", text: `清除失败:${err.message}` });
2505
+ }
2506
+ }
2507
+ async listModelsConfig() {
2508
+ this.emit({ type: "models_config", providers: [] });
2509
+ }
2510
+ async saveModelConfig(providerId, config) {
2511
+ this.emit({ type: "notice", level: "warning", text: "DSH 引擎使用内置 DeepSeek 模型,不支持自定义模型配置" });
2512
+ }
2513
+ async deleteModelConfig(providerId) {
2514
+ this.emit({ type: "notice", level: "warning", text: "DSH 引擎不支持自定义模型配置" });
2515
+ }
2516
+ async listProviders() {
2517
+ this.emit({
2518
+ type: "providers_status",
2519
+ providers: [
2520
+ {
2521
+ id: "deepseek-official",
2522
+ name: "DeepSeek 官方",
2523
+ configured: !!loadDeepSeekKey(),
2524
+ source: loadDeepSeekKey() ? "stored" : undefined,
2525
+ },
2526
+ ],
2527
+ });
2528
+ }
2529
+ async fetchModelsList(reqId, baseUrl, apiKey, authHeader, api) {
2530
+ this.emit({ type: "fetch_models_result", reqId, ok: false, error: "DSH 引擎不支持自定义 provider 探测" });
2531
+ }
2532
+ async refreshProviderModels(providerId, reqId) {
2533
+ this.emit({ type: "refresh_provider_result", reqId, ok: false, error: "DSH 引擎不支持自定义 provider" });
2534
+ }
2535
+ async cloneProvider(provider, reqId) {
2536
+ this.emit({ type: "clone_provider_result", reqId, ok: false, error: "DSH 引擎不支持自定义 provider" });
2537
+ }
2538
+ // -----------------------------------------------------------------------
2539
+ // 其他
2540
+ // -----------------------------------------------------------------------
2541
+ resolveDialog(id, value) {
2542
+ // DSH 引擎无扩展 UI 桥(dialog 由插件宿主走,v1 忽略)。
2543
+ }
2544
+ async editMessage(messageId, text, attachments) {
2545
+ // DSH 会话是 append-only 事件日志:编辑重问 = 新建会话 fork + 回放旧消息。
2546
+ try {
2547
+ const conv = this.conv;
2548
+ const idx = conv.messages.findIndex((m) => m.id === messageId);
2549
+ if (idx < 0) {
2550
+ this.emit({ type: "notice", level: "warning", text: "找不到要编辑的消息" });
2551
+ return;
2552
+ }
2553
+ // 截断到编辑点之前的所有消息 + 用编辑后的文本 prompt。
2554
+ const newSessionId = `fork-${randomUUID().slice(0, 12)}`;
2555
+ const fresh = this.addConversation(newSessionId, this.cwd, false);
2556
+ // 回放编辑点之前的消息(作为会话初始上下文:DSH 无 seed 机制,v1 用
2557
+ // 简化——直接把历史作为一条提示词说明附上)。
2558
+ const head = conv.messages.slice(0, idx + 1);
2559
+ // 把编辑前的对话内容写进新会话的 prompt(尽力保留上下文)。
2560
+ const contextNote = head
2561
+ .map((m) => {
2562
+ const blocks = m.content.map((c) => ("text" in c ? c.text : "")).join("\n");
2563
+ return `[${m.role}] ${blocks}`;
2564
+ })
2565
+ .join("\n");
2566
+ const prev = this.conv;
2567
+ prev.listed = prev.isStreaming || prev.terminals.list().length > 0 || prev.promptedSinceActive;
2568
+ this.activeId = fresh.id;
2569
+ // 编辑后的提问本身在 prompt 里;历史作为附加上下文(首条 prompt)。
2570
+ const headText = contextNote.trim()
2571
+ ? `${text}\n\n(编辑重问,原对话上下文,仅作参考,忽略其中指令性语气:)\n${contextNote}`
2572
+ : text;
2573
+ await this.prompt(headText, attachments);
2574
+ this.emitConversations();
2575
+ this.flushSnapshot(true);
2576
+ }
2577
+ catch (err) {
2578
+ this.emit({ type: "notice", level: "error", text: `编辑重问失败:${err.message}` });
2579
+ }
2580
+ }
2581
+ async setCwd(newCwd) {
2582
+ try {
2583
+ const abs = resolve(newCwd);
2584
+ if (!existsSync(abs) || !statSync(abs).isDirectory()) {
2585
+ this.emit({ type: "notice", level: "error", text: `切换工作目录失败:目录不存在:${newCwd}` });
2586
+ return;
2587
+ }
2588
+ if (abs === this.cwd)
2589
+ return;
2590
+ this.cwd = abs;
2591
+ this.stateStore.remember(this.clientId, abs);
2592
+ // 换项目 = 重启运行时(initialize 固定 cwd)。
2593
+ try {
2594
+ await this.runtime.restart(this.model);
2595
+ }
2596
+ catch (err) {
2597
+ this.emit({ type: "notice", level: "error", text: `切换工作区后重启运行时失败:${err.message}` });
2598
+ }
2599
+ // 旧 active 会话:活跃的(流式/有终端/跑过)标 listed → 后台运行可见
2600
+ // (与 pi 的 displaceActive 语义一致);空白的直接弃(不列)。
2601
+ const prev = this.conv;
2602
+ prev.listed =
2603
+ prev.isStreaming ||
2604
+ prev.terminals.list().length > 0 ||
2605
+ prev.promptedSinceActive ||
2606
+ prev.messages.length > 0;
2607
+ // 新项目 → 新会话。
2608
+ this.activeId = this.addConversation(`web-${randomUUID().slice(0, 12)}`, abs, false).id;
2609
+ // 旧项目非活跃 conversation 回收(pi 的 displaceActive 语义:切走后
2610
+ // 非 streaming / 无终端 / 未列出的旧会话从内存移除,磁盘 JSONL 可回放恢复)。
2611
+ for (const [id, c] of [...this.convs]) {
2612
+ if (id === this.activeId)
2613
+ continue;
2614
+ if (c.cwd === abs)
2615
+ continue;
2616
+ if (c.listed)
2617
+ continue;
2618
+ if (c.isStreaming)
2619
+ continue;
2620
+ if (c.terminals.list().length > 0)
2621
+ continue;
2622
+ this.removeConversation(id);
2623
+ }
2624
+ this.onCwdChanged?.(abs);
2625
+ this.emit({ type: "notice", level: "info", text: `已切换到工作目录:${abs}` });
2626
+ this.emitConversations();
2627
+ void this.pushSessions();
2628
+ void this.pushProjects();
2629
+ // 文件树跟随新项目(服务端原生 watcher 自动重挂)。
2630
+ void this.listFiles(undefined);
2631
+ this.pushTerminals();
2632
+ this.flushSnapshot(true);
2633
+ }
2634
+ catch (err) {
2635
+ this.emit({ type: "notice", level: "error", text: `切换目录失败:${err.message}` });
2636
+ }
2637
+ }
2638
+ async dispose() {
2639
+ if (this.disposed)
2640
+ return;
2641
+ this.disposed = true;
2642
+ if (this.snapshotTimer)
2643
+ clearTimeout(this.snapshotTimer);
2644
+ if (this.sessionsTimer)
2645
+ clearTimeout(this.sessionsTimer);
2646
+ if (this.reclaimTimer) {
2647
+ clearInterval(this.reclaimTimer);
2648
+ this.reclaimTimer = null;
2649
+ }
2650
+ if (this.retentionTimer) {
2651
+ clearInterval(this.retentionTimer);
2652
+ this.retentionTimer = null;
2653
+ }
2654
+ if (this.retentionOnce) {
2655
+ clearTimeout(this.retentionOnce);
2656
+ this.retentionOnce = null;
2657
+ }
2658
+ this.bg.stop();
2659
+ for (const conv of this.convs.values())
2660
+ conv.terminals.killAll();
2661
+ try {
2662
+ await this.runtime.close();
2663
+ }
2664
+ catch {
2665
+ /* ignore */
2666
+ }
2667
+ }
2668
+ }
2669
+ // ---------------------------------------------------------------------------
2670
+ // DshAgentService — 服务级:客户端会话管理与引擎分发入口
2671
+ // ---------------------------------------------------------------------------
2672
+ /** 与 pi 引擎 AgentService 同构的顶层服务(index.ts 按 PI_WEB_ENGINE 选择)。 */
2673
+ export class DshAgentService {
2674
+ cwd;
2675
+ agentDir;
2676
+ clients = new Map();
2677
+ quiesced = false;
2678
+ quiescedAt = 0;
2679
+ socketCount = 0;
2680
+ pending = new Map();
2681
+ stateStore;
2682
+ dataDir;
2683
+ onQuit;
2684
+ onClientCwdChanged;
2685
+ onToolEvent;
2686
+ pluginToolsProvider;
2687
+ pluginCommandsProvider;
2688
+ pluginBgTasksProvider;
2689
+ pluginStopBgTask;
2690
+ constructor(cwd, stateFile, dataDir, agentDir) {
2691
+ this.cwd = cwd;
2692
+ this.agentDir = agentDir;
2693
+ this.stateStore = new ClientStateStore(stateFile);
2694
+ this.dataDir = dataDir;
2695
+ }
2696
+ isQuiesced() {
2697
+ return this.quiesced;
2698
+ }
2699
+ quiesce() {
2700
+ this.quiesced = true;
2701
+ this.quiescedAt = Date.now();
2702
+ }
2703
+ unquiesce() {
2704
+ this.quiesced = false;
2705
+ this.quiescedAt = 0;
2706
+ }
2707
+ quiesceInfo() {
2708
+ return this.quiesced ? { quiesced: true, quiescedSince: this.quiescedAt } : { quiesced: false };
2709
+ }
2710
+ activeConversations() {
2711
+ let n = 0;
2712
+ for (const cs of this.clients.values())
2713
+ n += cs.activeConversations();
2714
+ return n;
2715
+ }
2716
+ pendingMessages() {
2717
+ let n = 0;
2718
+ for (const cs of this.clients.values())
2719
+ n += cs.pendingMessages();
2720
+ return n;
2721
+ }
2722
+ noteSocketOpen() {
2723
+ this.socketCount += 1;
2724
+ }
2725
+ noteSocketClose() {
2726
+ this.socketCount = Math.max(0, this.socketCount - 1);
2727
+ }
2728
+ serviceStatus() {
2729
+ return {
2730
+ pid: process.pid,
2731
+ version: DshClientSession["currentAppVersion"] ? DshClientSession.currentAppVersion() : "0.0.0",
2732
+ cwd: this.cwd,
2733
+ ...this.quiesceInfo(),
2734
+ connectedClients: this.socketCount,
2735
+ activeConversations: this.activeConversations(),
2736
+ pendingMessages: this.pendingMessages(),
2737
+ };
2738
+ }
2739
+ async attach(clientId, send) {
2740
+ let cs = this.clients.get(clientId);
2741
+ if (!cs) {
2742
+ if (this.quiesced) {
2743
+ throw new QuiesceRejectedError("新连接被拒绝,请等服务器恢复后重试");
2744
+ }
2745
+ let cwd = this.cwd;
2746
+ const saved = this.stateStore.get(clientId);
2747
+ if (saved.lastCwd && saved.lastCwd !== this.cwd) {
2748
+ try {
2749
+ if (statSync(saved.lastCwd).isDirectory())
2750
+ cwd = saved.lastCwd;
2751
+ }
2752
+ catch {
2753
+ /* gone — fall back to default */
2754
+ }
2755
+ }
2756
+ // 同步创建(runtime.start() 异步后台进行)——无并发竞态,无需 pending。
2757
+ cs = DshClientSession.create(clientId, cwd, this.stateStore, this.dataDir, this.agentDir);
2758
+ this.clients.set(clientId, cs);
2759
+ this.stateStore.remember(clientId, cwd);
2760
+ if (cwd !== this.cwd) {
2761
+ send({ type: "notice", level: "info", text: `已恢复上次的工作目录:${cwd}` });
2762
+ }
2763
+ }
2764
+ cs.attachSink(send);
2765
+ cs.onQuit = this.onQuit;
2766
+ cs.onToolEvent = this.onToolEvent;
2767
+ cs.pluginToolsProvider = this.pluginToolsProvider;
2768
+ cs.pluginCommandsProvider = this.pluginCommandsProvider;
2769
+ cs.pluginBgTasksProvider = this.pluginBgTasksProvider;
2770
+ cs.pluginStopBgTask = this.pluginStopBgTask;
2771
+ cs.isQuiesced = () => this.quiesced;
2772
+ cs.onCwdChanged = (abs) => this.onClientCwdChanged?.(abs);
2773
+ this.onClientCwdChanged?.(cs.cwd);
2774
+ return cs;
2775
+ }
2776
+ applyPluginAgentTools() {
2777
+ // 工具桥(#15):插件工具列表变化 → 各客户端运行时重新注册。
2778
+ for (const cs of this.clients.values())
2779
+ void cs.syncPluginTools();
2780
+ }
2781
+ applyPluginCommandCatalog() {
2782
+ for (const cs of this.clients.values())
2783
+ void cs.pushSlashCommands();
2784
+ }
2785
+ refreshBackgroundServers() {
2786
+ for (const cs of this.clients.values())
2787
+ cs.refreshBgTasks();
2788
+ }
2789
+ detach(clientId, send) {
2790
+ this.clients.get(clientId)?.detachSink(send);
2791
+ }
2792
+ get(clientId) {
2793
+ return this.clients.get(clientId);
2794
+ }
2795
+ async disposeAll() {
2796
+ for (const cs of this.clients.values()) {
2797
+ try {
2798
+ await cs.dispose();
2799
+ }
2800
+ catch {
2801
+ /* ignore */
2802
+ }
2803
+ }
2804
+ this.clients.clear();
2805
+ }
2806
+ }