pi-web-ui 0.68.0 → 0.68.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/pi-web-ui.mjs +1876 -1876
- package/dist/server/agent-service.js +450 -68
- package/dist/server/attachments.js +3 -3
- package/dist/server/client-state.js +4 -2
- package/dist/server/dsh/dsh-agent-service.js +15 -1
- package/dist/server/dsh/runtime/cordis.yml +1 -1
- package/dist/server/dsh/runtime/goal-rpc.mjs +645 -645
- package/dist/server/dsh/runtime/launcher.mjs +164 -164
- package/dist/server/dsh/runtime/override.patch.yml +71 -71
- package/dist/server/dsh/runtime/runtime-root.mjs +92 -86
- package/dist/server/index.js +68 -2
- package/dist/server/locales.js +156 -0
- package/dist/server/plugins.js +63 -0
- package/dist/server/prompt-composer.js +34 -0
- package/dist/server/serialize.js +24 -0
- package/dist/server/settings-service.js +9 -0
- package/dist/server/update-check.js +49 -50
- package/dist/server/vision-bridge.js +9 -9
- package/extensions/webui.ts +190 -190
- package/package.json +6 -2
- package/plugins/catalog.json +9 -0
- package/web/dist/assets/TerminalPanel-BQ5NTB9Y.js +6 -0
- package/web/dist/assets/TerminalPanel-DOrYoP_4.css +32 -0
- package/web/dist/assets/index-C_I-6Zul.css +10 -0
- package/web/dist/assets/index-Ck5pa3XK.js +333 -0
- package/web/dist/assets/markdown-DOsihKaR.js +51 -0
- package/web/dist/assets/{react-C9ovnpIm.js → react-DIP6JKYk.js} +2 -2
- package/web/dist/assets/xterm-B96xOxS9.js +38 -0
- package/web/dist/index.html +4 -4
- package/web/dist/assets/TerminalPanel-6GBZ9nXN.css +0 -32
- package/web/dist/assets/TerminalPanel-CTY_LN4U.js +0 -6
- package/web/dist/assets/index-Dc6t3GTo.css +0 -10
- package/web/dist/assets/index-fwF-jmiV.js +0 -332
- package/web/dist/assets/markdown-DRBrS2Nf.js +0 -51
- package/web/dist/assets/xterm-D1D2FVe3.js +0 -38
|
@@ -14,7 +14,7 @@ import { spawn } from "node:child_process";
|
|
|
14
14
|
import { randomUUID } from "node:crypto";
|
|
15
15
|
import { createRequire } from "node:module";
|
|
16
16
|
import { existsSync, readFileSync, rmSync, statSync, mkdirSync, watch } from "node:fs";
|
|
17
|
-
import { basename, dirname, join, resolve, sep } from "node:path";
|
|
17
|
+
import { basename, delimiter, dirname, join, resolve, sep } from "node:path";
|
|
18
18
|
import { fileURLToPath } from "node:url";
|
|
19
19
|
import { createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, createBashTool, createLocalBashOperations, getAgentDir, SessionManager, VERSION, } from "@earendil-works/pi-coding-agent";
|
|
20
20
|
import { Type } from "typebox";
|
|
@@ -27,7 +27,7 @@ import { GoalService } from "./goal-service.js";
|
|
|
27
27
|
import { MarkerService } from "./marker-service.js";
|
|
28
28
|
import { SlashCommandsService, parseSlash } from "./slash-commands.js";
|
|
29
29
|
import { ModelAdminService } from "./model-admin.js";
|
|
30
|
-
import { FilesService, workspacePath } from "./files-service.js";
|
|
30
|
+
import { FilesService, MACHINE_ROOT, workspacePath } from "./files-service.js";
|
|
31
31
|
import { isExtensionDisabled, isExtensionEnabled, ClientStateStore } from "./client-state.js";
|
|
32
32
|
import { SubagentTemplatesStore } from "./subagent-templates.js";
|
|
33
33
|
import { applyHeadTail, makePersistentTerminalTools, makeTerminalBashTool, stripAnsi, TERMINAL_TOOLS_GUIDANCE, TERMINAL_TOOL_NAMES, } from "./terminals.js";
|
|
@@ -35,8 +35,8 @@ import { WebUIContext, mockThemeProxy } from "./webui-context.js";
|
|
|
35
35
|
import { makeEditSoftTool, SOFT_EDIT_TOOL_NAME } from "./edit-soft-tool.js";
|
|
36
36
|
import { makeSubagentTools, subagentTitle, } from "./subagents.js";
|
|
37
37
|
import { buildAttachmentMessages } from "./attachments.js";
|
|
38
|
-
import { BUILTIN_SOUL, DEFAULT_PROMPT_TEMPLATE, renderPromptTemplate, resolveSectionTexts, } from "./prompt-composer.js";
|
|
39
|
-
import { serializeMessage, serializeStreamingMessage } from "./serialize.js";
|
|
38
|
+
import { BUILTIN_SOUL, DEFAULT_PROMPT_TEMPLATE, buildToolsSchemaText, renderPromptTemplate, resolveSectionTexts, } from "./prompt-composer.js";
|
|
39
|
+
import { serializeMessage, serializeStreamingMessage, stripTransientRetryErrors, } from "./serialize.js";
|
|
40
40
|
import { loadCommands, saveCommandsFile, TerminalManager } from "./terminals.js";
|
|
41
41
|
const SNAPSHOT_INTERVAL_MS = 60;
|
|
42
42
|
/** While assistant deltas are flowing, live rendering is carried by
|
|
@@ -215,6 +215,67 @@ function makeMarkersListTool(getActiveId, markerSvc) {
|
|
|
215
215
|
},
|
|
216
216
|
};
|
|
217
217
|
}
|
|
218
|
+
/**
|
|
219
|
+
* 标准 pi 引擎的 ask_user_question 工具:模型调用时把问题桥到浏览器(复用 DSH
|
|
220
|
+
* 引擎的 question_pending/question_answer 协议,前端 DshQuestionDialog 富渲染),
|
|
221
|
+
* 阻塞 agent 循环直到用户在浏览器回答或取消。
|
|
222
|
+
*
|
|
223
|
+
* 标准 SDK 没有内建 ask_user_question,故由 pi-web-ui 以 customTool 注册(与
|
|
224
|
+
* bash/edit 同机制)。DSH 引擎走 goal-rpc 的 userQuestions provider,两者互不
|
|
225
|
+
* 冲突(各引擎各走各的)。
|
|
226
|
+
*
|
|
227
|
+
* askUser 签名带 {aborted} 快照而非完整 AbortSignal:customTool 的 execute 信号
|
|
228
|
+
* 服务于整个 agent 生命周期,这里按「已中止即拒绝」的最小语义处理,避免与其它
|
|
229
|
+
* 工具的取消逻辑纠缠。
|
|
230
|
+
*/
|
|
231
|
+
export function makeAskUserQuestionTool(clientSession) {
|
|
232
|
+
const QuestionOptionSchema = Type.Object({
|
|
233
|
+
label: Type.String({ description: "Display label for the option" }),
|
|
234
|
+
description: Type.Optional(Type.String({ description: "Optional description shown below label" })),
|
|
235
|
+
preview: Type.Optional(Type.String({
|
|
236
|
+
description: "Optional preview rendered below when this option is selected (markdown or HTML — use for mockups/code/config).",
|
|
237
|
+
})),
|
|
238
|
+
});
|
|
239
|
+
const QuestionSchema = Type.Object({
|
|
240
|
+
id: Type.String({ description: "Unique identifier for this question" }),
|
|
241
|
+
question: Type.String({ description: "The full question text to display (markdown/HTML ok)" }),
|
|
242
|
+
detail: Type.Optional(Type.String({ description: "Optional detail/context shown under the question" })),
|
|
243
|
+
header: Type.Optional(Type.String({ description: "Optional short header for this question" })),
|
|
244
|
+
options: Type.Optional(Type.Array(QuestionOptionSchema, { description: "Available options to choose from" })),
|
|
245
|
+
multiSelect: Type.Optional(Type.Boolean({ description: "Allow selecting multiple options (default: false)" })),
|
|
246
|
+
});
|
|
247
|
+
return {
|
|
248
|
+
name: "ask_user_question",
|
|
249
|
+
label: "Ask the user",
|
|
250
|
+
description: "Ask the user focused questions to pin down ambiguous requirements. Use for clarifying the task, confirming decisions, or getting preferences. Each question renders a browser dialog with markdown/HTML rich text; options may carry a `preview`. Submit or cancel to resume.",
|
|
251
|
+
parameters: Type.Object({
|
|
252
|
+
questions: Type.Array(QuestionSchema, { description: "Questions to ask the user" }),
|
|
253
|
+
}),
|
|
254
|
+
execute: async (_id, params, signal) => {
|
|
255
|
+
const qs = params.questions;
|
|
256
|
+
if (!Array.isArray(qs) || qs.length === 0) {
|
|
257
|
+
throw new Error("ask_user_question requires at least one question");
|
|
258
|
+
}
|
|
259
|
+
const answers = await clientSession.askUser(qs, {
|
|
260
|
+
aborted: signal?.aborted,
|
|
261
|
+
});
|
|
262
|
+
if (answers === null) {
|
|
263
|
+
throw new Error("用户取消了提问");
|
|
264
|
+
}
|
|
265
|
+
// 工具结果:把每道题的回答拼成简洁文本给模型,同时留 details 供 UI 展示。
|
|
266
|
+
const lines = answers.map((a) => {
|
|
267
|
+
const q = qs.find((q) => q.id === a.id);
|
|
268
|
+
const label = a.selected.join(", ");
|
|
269
|
+
const custom = a.custom?.trim() ? ` (wrote: ${a.custom.trim()})` : "";
|
|
270
|
+
return `${q?.header ?? q?.id ?? a.id}: ${label || "(no selection)"}${custom}`;
|
|
271
|
+
});
|
|
272
|
+
return {
|
|
273
|
+
content: [{ type: "text", text: lines.join("\n") }],
|
|
274
|
+
details: { answers },
|
|
275
|
+
};
|
|
276
|
+
},
|
|
277
|
+
};
|
|
278
|
+
}
|
|
218
279
|
/**
|
|
219
280
|
* 插件结构化工具 → SDK ToolDefinition。
|
|
220
281
|
* execute 返回值宽容处理:{content,details} 原样收编;字符串/对象包成文本块。
|
|
@@ -296,6 +357,37 @@ function extractAssistantTextFromContent(content) {
|
|
|
296
357
|
.join("\n");
|
|
297
358
|
}
|
|
298
359
|
export { workspacePath };
|
|
360
|
+
/** 轨迹事件 payload 封顶(可直接广播/持久化,不撑爆 storage.json)。 */
|
|
361
|
+
const RUN_TASK_CAP = 500;
|
|
362
|
+
const RUN_ARGS_CAP = 4000;
|
|
363
|
+
const RUN_RESULT_CAP = 4000;
|
|
364
|
+
function truncRun(s, cap) {
|
|
365
|
+
return s.length <= cap ? s : `${s.slice(0, cap)}\n… [truncated]`;
|
|
366
|
+
}
|
|
367
|
+
/** 从 SDK tool result 里抠可读文本预览(text 块拼接,图片/二进制占位,封顶)。 */
|
|
368
|
+
function previewToolResult(result) {
|
|
369
|
+
try {
|
|
370
|
+
const content = result?.content;
|
|
371
|
+
if (Array.isArray(content)) {
|
|
372
|
+
const parts = [];
|
|
373
|
+
for (const c of content) {
|
|
374
|
+
if (c && typeof c === "object" && c.type === "text") {
|
|
375
|
+
parts.push(String(c.text ?? ""));
|
|
376
|
+
}
|
|
377
|
+
else {
|
|
378
|
+
parts.push("[…]");
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
return truncRun(parts.join("\n"), RUN_RESULT_CAP);
|
|
382
|
+
}
|
|
383
|
+
if (typeof result === "string")
|
|
384
|
+
return truncRun(result, RUN_RESULT_CAP);
|
|
385
|
+
return truncRun(JSON.stringify(result ?? null), RUN_RESULT_CAP);
|
|
386
|
+
}
|
|
387
|
+
catch {
|
|
388
|
+
return "[unserializable result]";
|
|
389
|
+
}
|
|
390
|
+
}
|
|
299
391
|
/** Hard cap on how long ONE tool call may run before the watchdog aborts the
|
|
300
392
|
* session. The SDK bash tool has NO default timeout, so a command that never
|
|
301
393
|
* finishes (servers, watchers, infinite loops) would otherwise hang the whole
|
|
@@ -494,6 +586,12 @@ export class ClientSession {
|
|
|
494
586
|
/** index.ts 注入(经 AgentService 拷贝到每个新会话):把 SDK 工具执行事件转发给
|
|
495
587
|
* 插件(PluginManager.emitToolEvent)。未设置时不做任何事。 */
|
|
496
588
|
onToolEvent = undefined;
|
|
589
|
+
/** index.ts 注入:把运行轨迹事件转发给插件(PluginManager.emitRunEvent,
|
|
590
|
+
* 轨迹视图插件靠它聚合时间线)。未设置时不做任何事。 */
|
|
591
|
+
onRunEvent = undefined;
|
|
592
|
+
/** index.ts 注入:当前打开对话变了(切历史会话/切 running 对话/新对话)时
|
|
593
|
+
* 通知插件(PluginManager.emitConversationChanged)——轨迹视图靠它重拉。 */
|
|
594
|
+
onConversationChanged = undefined;
|
|
497
595
|
/** index.ts 注入:读取插件当前注册的 AI 工具(attach 时拷贝到每个新会话)。 */
|
|
498
596
|
pluginToolsProvider = undefined;
|
|
499
597
|
/** index.ts 注入:读取插件当前注册的斜杠命令(目录展示 + prompt 拦截执行)。 */
|
|
@@ -726,6 +824,10 @@ export class ClientSession {
|
|
|
726
824
|
/** 子代理最近一次运行的结局:最后一条 assistant 消息的 errorMessage / stopReason。
|
|
727
825
|
* 报错 > 中止 > 正常,三者互斥;无 assistant 消息时返回空。 */
|
|
728
826
|
subagentRunOutcome(conv) {
|
|
827
|
+
// 自动重试等待期结局未定:瞬时 error 不算失败,避免向主对话误报
|
|
828
|
+
// 「子代理运行失败」(耗尽后 auto_retry_end 清旗,真正失败照常通知)。
|
|
829
|
+
if (conv.retryState)
|
|
830
|
+
return {};
|
|
729
831
|
try {
|
|
730
832
|
const msgs = conv.session.agent.state.messages;
|
|
731
833
|
for (let i = msgs.length - 1; i >= 0; i--) {
|
|
@@ -827,6 +929,7 @@ export class ClientSession {
|
|
|
827
929
|
const active = sess.getActiveToolNames();
|
|
828
930
|
const snippets = {};
|
|
829
931
|
const guidelines = [];
|
|
932
|
+
const schemaEntries = [];
|
|
830
933
|
for (const name of active) {
|
|
831
934
|
const def = sess.getToolDefinition(name);
|
|
832
935
|
if (!def)
|
|
@@ -835,6 +938,11 @@ export class ClientSession {
|
|
|
835
938
|
snippets[name] = def.promptSnippet;
|
|
836
939
|
if (def.promptGuidelines)
|
|
837
940
|
guidelines.push(...def.promptGuidelines);
|
|
941
|
+
schemaEntries.push({
|
|
942
|
+
name,
|
|
943
|
+
description: def.description,
|
|
944
|
+
parameters: def.parameters,
|
|
945
|
+
});
|
|
838
946
|
}
|
|
839
947
|
const loader = sess.resourceLoader;
|
|
840
948
|
const texts = resolveSectionTexts(this.composeInputs({
|
|
@@ -854,7 +962,7 @@ export class ClientSession {
|
|
|
854
962
|
const ovs = this.settingsSvc.current.promptOverrides ?? {};
|
|
855
963
|
const hasOverride = Object.values(ovs).some((v) => typeof v === "string" && v.trim());
|
|
856
964
|
const rendered = !tpl && !hasOverride ? undefined : renderPromptTemplate(tpl || DEFAULT_PROMPT_TEMPLATE, texts, ovs);
|
|
857
|
-
return { texts, full: rendered ?? sess.systemPrompt };
|
|
965
|
+
return { texts, full: rendered ?? sess.systemPrompt, toolsSchema: buildToolsSchemaText(schemaEntries) };
|
|
858
966
|
}
|
|
859
967
|
catch {
|
|
860
968
|
// Session not ready yet.
|
|
@@ -864,7 +972,7 @@ export class ClientSession {
|
|
|
864
972
|
/** 设置面板预览用的 host 回调(见 SettingsHost.promptSnapshot):完整生效提示词
|
|
865
973
|
* + 各来源默认(自动)内容。会话未就绪时给空值,面板保持可编辑但不预览。 */
|
|
866
974
|
promptSnapshot() {
|
|
867
|
-
return this.sessionPromptSnapshot() ?? { full: "", texts: {} };
|
|
975
|
+
return this.sessionPromptSnapshot() ?? { full: "", texts: {}, toolsSchema: "" };
|
|
868
976
|
}
|
|
869
977
|
/** Web-facing extension UI context (widgets, notifications). */
|
|
870
978
|
webUi = new WebUIContext((msg) => this.emit(msg));
|
|
@@ -959,6 +1067,14 @@ export class ClientSession {
|
|
|
959
1067
|
subagentTemplates;
|
|
960
1068
|
/** 内置标记服务(todo/notify/svc/rename 等,可全局/分组开关)。 */
|
|
961
1069
|
markerSvc;
|
|
1070
|
+
// -----------------------------------------------------------------------
|
|
1071
|
+
// 用户提问桥(标准 pi 引擎的 ask_user_question customTool):与 DSH 引擎的
|
|
1072
|
+
// question_pending/question_answer 同协议。模型调 ask_user_question 工具 →
|
|
1073
|
+
// 本桥发 question_pending 给浏览器 → 等 question_answer → resolve/reject
|
|
1074
|
+
// 工具结果(agent 循环阻塞)。一次只展示一个提问(agent 阻塞在工具执行)。
|
|
1075
|
+
// -----------------------------------------------------------------------
|
|
1076
|
+
questionSeq = 0;
|
|
1077
|
+
pendingQuestions = new Map();
|
|
962
1078
|
constructor(clientId, cwd, agentDir, stateStore) {
|
|
963
1079
|
this.clientId = clientId;
|
|
964
1080
|
this.cwd = cwd;
|
|
@@ -1235,6 +1351,10 @@ export class ClientSession {
|
|
|
1235
1351
|
...makeSubagentTools(this.subagentHost),
|
|
1236
1352
|
// 内置标记只读查询工具(todo/svc 状态查询,写操作走内联标记)。
|
|
1237
1353
|
makeMarkersListTool(() => this.activeId, this.markerSvc),
|
|
1354
|
+
// 标准引擎的 ask_user_question:模型调用 → 浏览器富渲染问卷(复用 DSH
|
|
1355
|
+
// 的 question_pending/question_answer 协议,前端 DshQuestionDialog)。
|
|
1356
|
+
// DSH 引擎不经此(它走 goal-rpc 的 userQuestions provider)。
|
|
1357
|
+
makeAskUserQuestionTool(this),
|
|
1238
1358
|
],
|
|
1239
1359
|
});
|
|
1240
1360
|
// 终端工具开关从创建起就生效(工具始终注册进注册表,只调活跃集)。
|
|
@@ -1456,6 +1576,76 @@ export class ClientSession {
|
|
|
1456
1576
|
clearTimeout(t);
|
|
1457
1577
|
conv.toolWatchdogs.clear();
|
|
1458
1578
|
}
|
|
1579
|
+
/** 发一条运行轨迹事件给插件(host.onRunEvent 订阅者,如轨迹视图插件)。
|
|
1580
|
+
* 异常隔离——序列化/插件坏了只记日志,绝不影响主流程。 */
|
|
1581
|
+
emitRun(conv, ev) {
|
|
1582
|
+
if (!this.onRunEvent)
|
|
1583
|
+
return;
|
|
1584
|
+
try {
|
|
1585
|
+
this.onRunEvent({ ...ev, conversationId: conv.id, at: Date.now() });
|
|
1586
|
+
}
|
|
1587
|
+
catch (err) {
|
|
1588
|
+
console.error("[agent-service] onRunEvent failed:", err);
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
/** 当前打开对话变了 → 通知插件重拉(切历史会话/切 running 对话/新对话)。
|
|
1592
|
+
* 异常隔离——插件坏了只记日志,绝不影响切换流程。 */
|
|
1593
|
+
notifyConversationChanged() {
|
|
1594
|
+
if (!this.onConversationChanged)
|
|
1595
|
+
return;
|
|
1596
|
+
try {
|
|
1597
|
+
this.onConversationChanged();
|
|
1598
|
+
}
|
|
1599
|
+
catch (err) {
|
|
1600
|
+
console.error("[agent-service] onConversationChanged failed:", err);
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
/** 插件用:本客户端最近活跃对话的快照(轨迹视图直接显示打开对话的时间线)。
|
|
1604
|
+
* messages/streamingMessage 为引用稳定的只读缓存对象——调用方只读、不得修改。 */
|
|
1605
|
+
readConversationForPlugins() {
|
|
1606
|
+
try {
|
|
1607
|
+
let target = null;
|
|
1608
|
+
for (const c of this.convs.values()) {
|
|
1609
|
+
if (!target || c.lastActiveAt > target.lastActiveAt)
|
|
1610
|
+
target = c;
|
|
1611
|
+
}
|
|
1612
|
+
if (!target)
|
|
1613
|
+
return null;
|
|
1614
|
+
const state = target.session.agent.state;
|
|
1615
|
+
let stats = {
|
|
1616
|
+
totalMessages: 0,
|
|
1617
|
+
tokens: { input: 0, output: 0, total: 0 },
|
|
1618
|
+
cost: 0,
|
|
1619
|
+
};
|
|
1620
|
+
try {
|
|
1621
|
+
const s = target.session.getSessionStats();
|
|
1622
|
+
stats = { totalMessages: s.totalMessages, tokens: s.tokens, cost: s.cost };
|
|
1623
|
+
}
|
|
1624
|
+
catch {
|
|
1625
|
+
/* stats 尽力而为 */
|
|
1626
|
+
}
|
|
1627
|
+
let streamingMessage = null;
|
|
1628
|
+
try {
|
|
1629
|
+
streamingMessage = state.streamingMessage ? serializeStreamingMessage(state.streamingMessage) : null;
|
|
1630
|
+
}
|
|
1631
|
+
catch {
|
|
1632
|
+
/* 尽力而为 */
|
|
1633
|
+
}
|
|
1634
|
+
return {
|
|
1635
|
+
conversationId: target.id,
|
|
1636
|
+
title: target.title,
|
|
1637
|
+
at: target.lastActiveAt,
|
|
1638
|
+
isStreaming: target.session.isStreaming,
|
|
1639
|
+
messages: this.messagesOf(target),
|
|
1640
|
+
streamingMessage,
|
|
1641
|
+
stats,
|
|
1642
|
+
};
|
|
1643
|
+
}
|
|
1644
|
+
catch (err) {
|
|
1645
|
+
console.error("[agent-service] readConversationForPlugins failed:", err);
|
|
1646
|
+
return null;
|
|
1647
|
+
}
|
|
1648
|
+
}
|
|
1459
1649
|
onEvent(conv, event) {
|
|
1460
1650
|
// Any SDK event proves the run is alive — feeds the stall watchdog below.
|
|
1461
1651
|
conv.lastSdkEventAt = Date.now();
|
|
@@ -1485,7 +1675,26 @@ export class ClientSession {
|
|
|
1485
1675
|
}
|
|
1486
1676
|
this.armToolWatchdog(conv, event.toolCallId);
|
|
1487
1677
|
// 插件扩展点:工具开始执行(异常由 emitToolEvent 隔离)。
|
|
1488
|
-
this.onToolEvent?.({
|
|
1678
|
+
this.onToolEvent?.({
|
|
1679
|
+
phase: "start",
|
|
1680
|
+
toolName: event.toolName,
|
|
1681
|
+
conversationId: conv.id,
|
|
1682
|
+
toolCallId: event.toolCallId,
|
|
1683
|
+
});
|
|
1684
|
+
// 轨迹事件:带参数预览(JSON 封顶;超大参数只记截断)。
|
|
1685
|
+
let argsText = "null";
|
|
1686
|
+
try {
|
|
1687
|
+
argsText = truncRun(JSON.stringify(event.args ?? null), RUN_ARGS_CAP);
|
|
1688
|
+
}
|
|
1689
|
+
catch {
|
|
1690
|
+
argsText = "[unserializable args]";
|
|
1691
|
+
}
|
|
1692
|
+
this.emitRun(conv, {
|
|
1693
|
+
type: "tool_start",
|
|
1694
|
+
toolCallId: event.toolCallId,
|
|
1695
|
+
toolName: event.toolName,
|
|
1696
|
+
argsText,
|
|
1697
|
+
});
|
|
1489
1698
|
break;
|
|
1490
1699
|
}
|
|
1491
1700
|
case "tool_execution_end": {
|
|
@@ -1502,6 +1711,16 @@ export class ClientSession {
|
|
|
1502
1711
|
phase: "end",
|
|
1503
1712
|
toolName: event.toolName,
|
|
1504
1713
|
conversationId: conv.id,
|
|
1714
|
+
toolCallId: event.toolCallId,
|
|
1715
|
+
...(durationMs !== undefined ? { durationMs } : {}),
|
|
1716
|
+
isError: event.isError,
|
|
1717
|
+
});
|
|
1718
|
+
// 轨迹事件:带结果预览(封顶)+ 耗时/错误标志。
|
|
1719
|
+
this.emitRun(conv, {
|
|
1720
|
+
type: "tool_end",
|
|
1721
|
+
toolCallId: event.toolCallId,
|
|
1722
|
+
toolName: event.toolName,
|
|
1723
|
+
resultText: previewToolResult(event.result),
|
|
1505
1724
|
...(durationMs !== undefined ? { durationMs } : {}),
|
|
1506
1725
|
isError: event.isError,
|
|
1507
1726
|
});
|
|
@@ -1597,9 +1816,58 @@ export class ClientSession {
|
|
|
1597
1816
|
}
|
|
1598
1817
|
break;
|
|
1599
1818
|
}
|
|
1819
|
+
case "auto_retry_start": {
|
|
1820
|
+
// 大模型 API 瞬时报错,SDK 退避重试:填实重试信息。末尾 error
|
|
1821
|
+
// 消息已被(或即将被)SDK 从 state 摘掉,currentMessages() 凭此旗
|
|
1822
|
+
// 过滤,快照只显示温和的重试条。落盘由底部检查点立即 flush。
|
|
1823
|
+
conv.retryState = {
|
|
1824
|
+
attempt: event.attempt,
|
|
1825
|
+
maxAttempts: event.maxAttempts,
|
|
1826
|
+
delayMs: event.delayMs,
|
|
1827
|
+
errorMessage: event.errorMessage,
|
|
1828
|
+
};
|
|
1829
|
+
break;
|
|
1830
|
+
}
|
|
1831
|
+
case "auto_retry_end": {
|
|
1832
|
+
// 重试结束:成功 → 新内容照常显示;耗尽 → error 消息留驻,
|
|
1833
|
+
// 快照永久标红。落盘由底部检查点立即 flush。
|
|
1834
|
+
conv.retryState = null;
|
|
1835
|
+
break;
|
|
1836
|
+
}
|
|
1600
1837
|
// A run finished or a new entry was persisted — keep the session list fresh
|
|
1601
1838
|
// (new chat + first message, completed turns, compaction, etc.).
|
|
1602
1839
|
case "agent_end": {
|
|
1840
|
+
// 可重试错误:SDK 随后发 auto_retry_start 并把末尾 error 消息从
|
|
1841
|
+
// state 摘掉。这里先立占位,让本次立即 flush 的快照就不含瞬时红错
|
|
1842
|
+
// ——否则快照先画红、摘掉后又消失,即「红色报错一闪而过」。
|
|
1843
|
+
if (event.willRetry) {
|
|
1844
|
+
let errorMessage = "";
|
|
1845
|
+
for (let i = event.messages.length - 1; i >= 0; i--) {
|
|
1846
|
+
const m = event.messages[i];
|
|
1847
|
+
if (m.role === "assistant" && typeof m.errorMessage === "string") {
|
|
1848
|
+
errorMessage = m.errorMessage;
|
|
1849
|
+
break;
|
|
1850
|
+
}
|
|
1851
|
+
}
|
|
1852
|
+
conv.retryState = { attempt: 0, maxAttempts: 0, delayMs: 0, errorMessage };
|
|
1853
|
+
}
|
|
1854
|
+
else {
|
|
1855
|
+
// 本轮结束且无后续重试:任何残留占位都是过期的(会话替换、
|
|
1856
|
+
// 结束信号丢失等),清掉,否则横幅会卡住不消失。
|
|
1857
|
+
conv.retryState = null;
|
|
1858
|
+
}
|
|
1859
|
+
// 轨迹事件:本轮结束(放最前——aborted 中断路径也会 break,
|
|
1860
|
+
// 轨迹里必须留下「已停止」而不是凭空消失)。
|
|
1861
|
+
try {
|
|
1862
|
+
const lastAssistant = [...event.messages].reverse().find((m) => {
|
|
1863
|
+
const a = m;
|
|
1864
|
+
return a.role === "assistant" && typeof a.stopReason === "string";
|
|
1865
|
+
});
|
|
1866
|
+
this.emitRun(conv, lastAssistant?.stopReason ? { type: "run_end", stopReason: lastAssistant.stopReason } : { type: "run_end" });
|
|
1867
|
+
}
|
|
1868
|
+
catch {
|
|
1869
|
+
/* 轨迹尽力而为 */
|
|
1870
|
+
}
|
|
1603
1871
|
this.scheduleSessionsRefresh();
|
|
1604
1872
|
this.refreshConversationTitle(conv);
|
|
1605
1873
|
// 内联标记不在此兜底扫最后一条 assistant:每条气泡结束已走 message_end
|
|
@@ -1659,16 +1927,47 @@ export class ClientSession {
|
|
|
1659
1927
|
break;
|
|
1660
1928
|
}
|
|
1661
1929
|
case "message_end": {
|
|
1930
|
+
// 轨迹事件:一条消息定稿(user/assistant 都收;custom display:false
|
|
1931
|
+
// 的 serializeMessage 返回 null 时跳过)。
|
|
1932
|
+
try {
|
|
1933
|
+
const ui = serializeMessage(event.message, 0);
|
|
1934
|
+
if (ui)
|
|
1935
|
+
this.emitRun(conv, { type: "message", message: ui });
|
|
1936
|
+
}
|
|
1937
|
+
catch {
|
|
1938
|
+
/* 轨迹尽力而为 */
|
|
1939
|
+
}
|
|
1940
|
+
// 每条 assistant 气泡流式结束 → 立即解析其中的内联标记:每个气泡各自
|
|
1662
1941
|
// 每条 assistant 气泡流式结束 → 立即解析其中的内联标记:每个气泡各自
|
|
1663
1942
|
// 生效(不再等整轮 agent_end),同一轮里先前消息的标记也不再丢。
|
|
1664
1943
|
const mm = event.message;
|
|
1665
1944
|
if (mm?.role !== "assistant")
|
|
1666
1945
|
break;
|
|
1946
|
+
// 非 error 的 assistant 定稿 = 重试周期结束(与 SDK 重置
|
|
1947
|
+
// _retryAttempt 的条件一致):即使 auto_retry_end 丢失,横幅也不会卡住。
|
|
1948
|
+
if (mm.stopReason !== "error")
|
|
1949
|
+
conv.retryState = null;
|
|
1667
1950
|
const text = extractAssistantTextFromContent(mm.content);
|
|
1668
1951
|
if (text && text.includes("[["))
|
|
1669
1952
|
void this.markerSvc.handleAssistantText(conv.id, text);
|
|
1670
1953
|
break;
|
|
1671
1954
|
}
|
|
1955
|
+
case "agent_start": {
|
|
1956
|
+
// 轨迹事件:新一轮开始(任务文本由 prompt() 暂存;steer/内部续跑
|
|
1957
|
+
// 无暂存时省略,插件回退为「继续执行」)。
|
|
1958
|
+
const task = conv.pendingTask;
|
|
1959
|
+
conv.pendingTask = undefined;
|
|
1960
|
+
this.emitRun(conv, task ? { type: "run_start", task } : { type: "run_start" });
|
|
1961
|
+
break;
|
|
1962
|
+
}
|
|
1963
|
+
case "turn_start": {
|
|
1964
|
+
this.emitRun(conv, { type: "turn_start" });
|
|
1965
|
+
break;
|
|
1966
|
+
}
|
|
1967
|
+
case "turn_end": {
|
|
1968
|
+
this.emitRun(conv, { type: "turn_end" });
|
|
1969
|
+
break;
|
|
1970
|
+
}
|
|
1672
1971
|
case "message_update": {
|
|
1673
1972
|
// Live assistant-message increment, deliberately OUTSIDE the snapshot
|
|
1674
1973
|
// channel: send() drops snapshots under backpressure (big sessions),
|
|
@@ -1714,7 +2013,11 @@ export class ClientSession {
|
|
|
1714
2013
|
// Snapshot checkpoint policy: deltas carry live rendering during streaming;
|
|
1715
2014
|
// full snapshots are reconciliation checkpoints taken immediately at
|
|
1716
2015
|
// run/tool boundaries and on a slow timer otherwise.
|
|
1717
|
-
if (event.type === "agent_end" ||
|
|
2016
|
+
if (event.type === "agent_end" ||
|
|
2017
|
+
event.type === "tool_execution_end" ||
|
|
2018
|
+
event.type === "compaction_end" ||
|
|
2019
|
+
event.type === "auto_retry_start" ||
|
|
2020
|
+
event.type === "auto_retry_end") {
|
|
1718
2021
|
this.flushSnapshot();
|
|
1719
2022
|
}
|
|
1720
2023
|
else {
|
|
@@ -1751,7 +2054,10 @@ export class ClientSession {
|
|
|
1751
2054
|
}
|
|
1752
2055
|
/** Serialize a persisted message with a STABLE id + cached object reference. */
|
|
1753
2056
|
serializeCached(m) {
|
|
1754
|
-
|
|
2057
|
+
return this.serializeCachedFor(this.conv, m);
|
|
2058
|
+
}
|
|
2059
|
+
/** serializeCached 的按对话版本(插件快照读非活跃对话用;缓存仍按对话隔离)。 */
|
|
2060
|
+
serializeCachedFor(conv, m) {
|
|
1755
2061
|
// toolResult messages are keyed by toolCallId; everything else by
|
|
1756
2062
|
// role+timestamp. A single prompt can emit several same-role messages
|
|
1757
2063
|
// within the SAME millisecond (multiple attachment asides), so the
|
|
@@ -1801,10 +2107,17 @@ export class ClientSession {
|
|
|
1801
2107
|
* Element objects are reference-stable (serializeCached cache), which is
|
|
1802
2108
|
* what lets emitSnapshotNow detect append-only growth via identity walk. */
|
|
1803
2109
|
currentMessages() {
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
2110
|
+
return this.messagesOf(this.conv);
|
|
2111
|
+
}
|
|
2112
|
+
/** currentMessages 的按对话版本(插件快照读非活跃对话用)。 */
|
|
2113
|
+
messagesOf(conv) {
|
|
2114
|
+
let rawMessages = conv.session.agent.state.messages
|
|
2115
|
+
.map((m) => this.serializeCachedFor(conv, m))
|
|
1807
2116
|
.filter((m) => m !== null);
|
|
2117
|
+
// 自动重试等待期:SDK 暂留在 state 末尾的 error 气泡只是中间态(随后被
|
|
2118
|
+
// 摘掉重跑),不进快照——成功则用户永远看不到,耗尽才标红。否则 agent_end
|
|
2119
|
+
// 的立即 flush 会先画红、摘掉后又消失(红色一闪而过)。
|
|
2120
|
+
rawMessages = stripTransientRetryErrors(rawMessages, !!conv.retryState);
|
|
1808
2121
|
// Reuse the previous array when nothing changed: the element objects are
|
|
1809
2122
|
// cached (reference-stable) anyway, and a stable array reference lets the
|
|
1810
2123
|
// frontend memoize derived maps instead of rebuilding them every 60ms.
|
|
@@ -1843,6 +2156,13 @@ export class ClientSession {
|
|
|
1843
2156
|
catch {
|
|
1844
2157
|
// stats are best-effort
|
|
1845
2158
|
}
|
|
2159
|
+
// 流式 error 同样是中间态(定稿走 message_end/agent_end):先藏起
|
|
2160
|
+
// errorMessage,避免红色在 streaming 气泡里闪一下。最终失败会经由
|
|
2161
|
+
// messages 永久标红,不影响告警。
|
|
2162
|
+
let streamingMessage = state.streamingMessage ? serializeStreamingMessage(state.streamingMessage) : null;
|
|
2163
|
+
if (streamingMessage?.stopReason === "error") {
|
|
2164
|
+
streamingMessage = { ...streamingMessage, errorMessage: undefined };
|
|
2165
|
+
}
|
|
1846
2166
|
return {
|
|
1847
2167
|
clientId: this.clientId,
|
|
1848
2168
|
cwd: this.cwd,
|
|
@@ -1850,11 +2170,7 @@ export class ClientSession {
|
|
|
1850
2170
|
sessionFile: this.session.sessionFile,
|
|
1851
2171
|
conversationId: this.activeId,
|
|
1852
2172
|
rev,
|
|
1853
|
-
|
|
1854
|
-
// (the SDK only pushes it into state.messages at message_end). Surfacing
|
|
1855
|
-
// it here is what makes thinking + text stream into the browser at
|
|
1856
|
-
// ~60ms granularity instead of appearing only when the turn finishes.
|
|
1857
|
-
streamingMessage: state.streamingMessage ? serializeStreamingMessage(state.streamingMessage) : null,
|
|
2173
|
+
streamingMessage,
|
|
1858
2174
|
isStreaming: this.session.isStreaming,
|
|
1859
2175
|
model: model
|
|
1860
2176
|
? {
|
|
@@ -1870,6 +2186,7 @@ export class ClientSession {
|
|
|
1870
2186
|
availableThinkingLevels: this.session.getAvailableThinkingLevels(),
|
|
1871
2187
|
queue: { steering: conv.queueSteering, followUp: conv.queueFollowUp },
|
|
1872
2188
|
errorMessage: state.errorMessage,
|
|
2189
|
+
retry: conv.retryState ?? null,
|
|
1873
2190
|
tools: state.tools.map((t) => t.name),
|
|
1874
2191
|
version: ++this.version,
|
|
1875
2192
|
piConfigured: this.isPiConfigured(),
|
|
@@ -1930,6 +2247,55 @@ export class ClientSession {
|
|
|
1930
2247
|
resolveDialog(id, value) {
|
|
1931
2248
|
this.webUi.resolveDialog(id, value);
|
|
1932
2249
|
}
|
|
2250
|
+
// -----------------------------------------------------------------------
|
|
2251
|
+
// 用户提问桥(标准 pi 引擎 ask_user_question customTool)
|
|
2252
|
+
// -----------------------------------------------------------------------
|
|
2253
|
+
/** 标准引擎模型调 ask_user_question:发 question_pending 给浏览器并阻塞等待
|
|
2254
|
+
* question_answer。sig 为工具执行信号的当前状态(aborted → 立即 reject)。
|
|
2255
|
+
* 返回 answers(用户选中/自定义),或 null(用户取消)。 */
|
|
2256
|
+
askUser(questions, sig) {
|
|
2257
|
+
return new Promise((resolve, reject) => {
|
|
2258
|
+
if (sig?.aborted || this.disposed) {
|
|
2259
|
+
reject(new Error("ask_user_question 已中止"));
|
|
2260
|
+
return;
|
|
2261
|
+
}
|
|
2262
|
+
// 问卷开关(默认开):关 → 不弹对话框,立即报错让模型得知已禁用。
|
|
2263
|
+
if (this.settingsSvc.current.questionnaireEnabled === false) {
|
|
2264
|
+
reject(new Error("问卷功能已关闭,可在设置中重新开启"));
|
|
2265
|
+
return;
|
|
2266
|
+
}
|
|
2267
|
+
const id = `q-${++this.questionSeq}`;
|
|
2268
|
+
this.pendingQuestions.set(id, resolve);
|
|
2269
|
+
this.emit({
|
|
2270
|
+
type: "question_pending",
|
|
2271
|
+
id,
|
|
2272
|
+
questions,
|
|
2273
|
+
});
|
|
2274
|
+
});
|
|
2275
|
+
}
|
|
2276
|
+
/** 前端回答模型提问(question_answer → 恢复 askUser 的 Promise)。id 需匹配
|
|
2277
|
+
* pendingQuestions 中键;cancelled 或未匹配(例如用户早已切走)时按「取消」处理
|
|
2278
|
+
* —— 把挂起的提问全部 reject,让模型知道用户离开了。 */
|
|
2279
|
+
resolveQuestion(id, answers, cancelled) {
|
|
2280
|
+
const resolve = this.pendingQuestions.get(id);
|
|
2281
|
+
if (resolve) {
|
|
2282
|
+
this.pendingQuestions.delete(id);
|
|
2283
|
+
resolve(cancelled ? null : answers);
|
|
2284
|
+
}
|
|
2285
|
+
}
|
|
2286
|
+
/** 标准引擎的 question_answer 路由入口(index.ts 经 cs.answerQuestion?. 转发)。
|
|
2287
|
+
* DSH 引擎的 AgentService 也实现了同名方法,此处为 ClientSession 的转发。 */
|
|
2288
|
+
answerQuestion(id, answers, cancelled) {
|
|
2289
|
+
this.resolveQuestion(id, answers, cancelled);
|
|
2290
|
+
return Promise.resolve();
|
|
2291
|
+
}
|
|
2292
|
+
/** 关闭所有挂起提问(切对话 / dispose 时清理):以「取消」解析,避免模型挂死。 */
|
|
2293
|
+
cancelPendingQuestions() {
|
|
2294
|
+
for (const [, resolve] of this.pendingQuestions) {
|
|
2295
|
+
resolve(null);
|
|
2296
|
+
}
|
|
2297
|
+
this.pendingQuestions.clear();
|
|
2298
|
+
}
|
|
1933
2299
|
/**
|
|
1934
2300
|
* Whether the pi agent has at least one usable model. ModelRuntime's
|
|
1935
2301
|
* available snapshot already accounts for models.json, auth.json, env-var
|
|
@@ -1950,65 +2316,32 @@ export class ClientSession {
|
|
|
1950
2316
|
* probe). Cached machine-wide (same binary for every client) for 10s —
|
|
1951
2317
|
* the check is only rerun after install or when the cache expires.
|
|
1952
2318
|
*
|
|
1953
|
-
* The probe
|
|
1954
|
-
*
|
|
1955
|
-
*
|
|
1956
|
-
*
|
|
1957
|
-
*
|
|
1958
|
-
* between fork and exec
|
|
1959
|
-
*
|
|
1960
|
-
* connections. Observed reproducibly on Android/Termux (Node 26).
|
|
2319
|
+
* The probe is FORK-FREE: it scans PATH for the pi executable instead of
|
|
2320
|
+
* spawning `pi --version`. Do not reintroduce a spawn here — ANY fork on
|
|
2321
|
+
* the main thread of this multi-threaded server can deadlock the whole
|
|
2322
|
+
* process on Android/Termux (issue #78): libuv's uv_spawn blocks its
|
|
2323
|
+
* caller reading the child's error pipe, and that pipe never closes when
|
|
2324
|
+
* the forked child deadlocks between fork and exec. This applies to
|
|
2325
|
+
* asynchronous spawns too — the previous async probe reproduced the hang.
|
|
1961
2326
|
*/
|
|
1962
2327
|
static piCliProbe = null;
|
|
1963
|
-
static piCliProbePending = false;
|
|
1964
2328
|
static PI_CLI_PROBE_TTL_MS = 10_000;
|
|
1965
2329
|
isPiCliInstalled() {
|
|
1966
2330
|
const now = Date.now();
|
|
1967
2331
|
const cached = ClientSession.piCliProbe;
|
|
1968
2332
|
if (cached && now - cached.at < ClientSession.PI_CLI_PROBE_TTL_MS)
|
|
1969
2333
|
return cached.installed;
|
|
1970
|
-
ClientSession.
|
|
1971
|
-
|
|
2334
|
+
const installed = ClientSession.piCliOnPath();
|
|
2335
|
+
ClientSession.piCliProbe = { at: now, installed };
|
|
2336
|
+
return installed;
|
|
1972
2337
|
}
|
|
1973
|
-
static
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
try {
|
|
1979
|
-
proc = spawn("pi", ["--version"], {
|
|
1980
|
-
stdio: "ignore",
|
|
1981
|
-
// Windows: `pi` resolves to a pi.cmd shim — spawn can only
|
|
1982
|
-
// exec those through a shell (else ENOENT).
|
|
1983
|
-
shell: process.platform === "win32",
|
|
1984
|
-
});
|
|
2338
|
+
static piCliOnPath() {
|
|
2339
|
+
const dirs = (process.env.PATH ?? "").split(delimiter);
|
|
2340
|
+
for (const dir of dirs) {
|
|
2341
|
+
if (dir && existsSync(join(dir, "pi")))
|
|
2342
|
+
return true;
|
|
1985
2343
|
}
|
|
1986
|
-
|
|
1987
|
-
ClientSession.piCliProbe = { at: Date.now(), installed: false };
|
|
1988
|
-
ClientSession.piCliProbePending = false;
|
|
1989
|
-
return;
|
|
1990
|
-
}
|
|
1991
|
-
const finish = (installed) => {
|
|
1992
|
-
ClientSession.piCliProbe = { at: Date.now(), installed };
|
|
1993
|
-
ClientSession.piCliProbePending = false;
|
|
1994
|
-
};
|
|
1995
|
-
const timer = setTimeout(() => {
|
|
1996
|
-
try {
|
|
1997
|
-
proc.kill();
|
|
1998
|
-
}
|
|
1999
|
-
catch {
|
|
2000
|
-
/* already exited */
|
|
2001
|
-
}
|
|
2002
|
-
finish(false);
|
|
2003
|
-
}, 5000);
|
|
2004
|
-
proc.on("error", () => {
|
|
2005
|
-
clearTimeout(timer);
|
|
2006
|
-
finish(false);
|
|
2007
|
-
});
|
|
2008
|
-
proc.on("close", (code) => {
|
|
2009
|
-
clearTimeout(timer);
|
|
2010
|
-
finish(code === 0);
|
|
2011
|
-
});
|
|
2344
|
+
return false;
|
|
2012
2345
|
}
|
|
2013
2346
|
static invalidatePiCliProbe() {
|
|
2014
2347
|
ClientSession.piCliProbe = null;
|
|
@@ -2607,6 +2940,9 @@ export class ClientSession {
|
|
|
2607
2940
|
// is refused until admission reopens.
|
|
2608
2941
|
if (this.quiesceBlocked())
|
|
2609
2942
|
return;
|
|
2943
|
+
// 轨迹用:暂存本轮任务文本,下一轮 agent_start 消费(steer/内部续跑
|
|
2944
|
+
// 不经此处,届时 task 缺省,插件回退为「继续执行」)。
|
|
2945
|
+
conv.pendingTask = text.trim() ? truncRun(text.trim(), RUN_TASK_CAP) : undefined;
|
|
2610
2946
|
// Name the conversation from its FIRST prompt immediately, before any
|
|
2611
2947
|
// await: the typed text IS the name. The `conv` reference was captured
|
|
2612
2948
|
// before the try block, so a concurrent switch/new_chat while prompt()
|
|
@@ -3000,6 +3336,8 @@ export class ClientSession {
|
|
|
3000
3336
|
// The new runtime re-discovered skills/templates — refresh the catalog
|
|
3001
3337
|
// so the picker stops showing the previous runtime's list.
|
|
3002
3338
|
void this.pushSlashCommands();
|
|
3339
|
+
// 新对话即当前打开 → 插件重拉(轨迹视图跟随)。
|
|
3340
|
+
this.notifyConversationChanged();
|
|
3003
3341
|
}
|
|
3004
3342
|
catch (err) {
|
|
3005
3343
|
this.emit({
|
|
@@ -3115,6 +3453,8 @@ export class ClientSession {
|
|
|
3115
3453
|
void this.listFiles(undefined);
|
|
3116
3454
|
void this.listCommands();
|
|
3117
3455
|
}
|
|
3456
|
+
// 当前打开对话变了 → 插件重拉(轨迹视图切会话后即刷新,不等轮询)。
|
|
3457
|
+
this.notifyConversationChanged();
|
|
3118
3458
|
this.flushSnapshot();
|
|
3119
3459
|
}
|
|
3120
3460
|
/** Push every running conversation across ALL projects to the client. The
|
|
@@ -3565,6 +3905,8 @@ export class ClientSession {
|
|
|
3565
3905
|
this.pushTerminals();
|
|
3566
3906
|
// The restored conversation has a fresh project-bound resource cache.
|
|
3567
3907
|
void this.pushSlashCommands();
|
|
3908
|
+
// 切历史会话成功 → 插件重拉(轨迹视图立即显示该会话时间线)。
|
|
3909
|
+
this.notifyConversationChanged();
|
|
3568
3910
|
}
|
|
3569
3911
|
catch (err) {
|
|
3570
3912
|
openedTerminals?.killAll();
|
|
@@ -3812,10 +4154,25 @@ export class ClientSession {
|
|
|
3812
4154
|
}
|
|
3813
4155
|
async setCwd(newCwd) {
|
|
3814
4156
|
try {
|
|
3815
|
-
const { resolve } = await import("node:path");
|
|
4157
|
+
const { resolve, sep } = await import("node:path");
|
|
3816
4158
|
this.files.unwatchGit(); // stale repo's watcher must not fire across projects
|
|
3817
4159
|
const fs = await import("node:fs/promises");
|
|
3818
|
-
const
|
|
4160
|
+
const trimmed = newCwd.trim();
|
|
4161
|
+
if (trimmed === MACHINE_ROOT) {
|
|
4162
|
+
// 机器根是虚拟层(盘符列表),不能作工作目录——指引用户选具体目录。
|
|
4163
|
+
this.emit({
|
|
4164
|
+
type: "notice",
|
|
4165
|
+
level: "warning",
|
|
4166
|
+
text: "请选择一个具体目录作为工作目录(此电脑本身不是目录)",
|
|
4167
|
+
textEn: "Pick a concrete directory as the workspace (This PC itself is not a directory)",
|
|
4168
|
+
});
|
|
4169
|
+
return;
|
|
4170
|
+
}
|
|
4171
|
+
// Windows 裸盘符("C:"):resolve 会按该盘当前目录解析,必须显式指到盘根;
|
|
4172
|
+
// 仅 win32 生效——posix 下 "C:" 仍是普通相对路径,避免误伤同名目录。
|
|
4173
|
+
const abs = process.platform === "win32" && /^[A-Za-z]:$/.test(trimmed)
|
|
4174
|
+
? `${trimmed.toUpperCase()}${sep}`
|
|
4175
|
+
: resolve(trimmed);
|
|
3819
4176
|
const st = await fs.stat(abs);
|
|
3820
4177
|
if (!st.isDirectory()) {
|
|
3821
4178
|
throw new Error("路径不是目录");
|
|
@@ -3900,6 +4257,8 @@ export class ClientSession {
|
|
|
3900
4257
|
void this.listFiles(undefined);
|
|
3901
4258
|
// Commands are per-project (.pi/commands.json in the current cwd).
|
|
3902
4259
|
void this.listCommands();
|
|
4260
|
+
// 切项目即换了当前打开对话 → 插件重拉。
|
|
4261
|
+
this.notifyConversationChanged();
|
|
3903
4262
|
}
|
|
3904
4263
|
catch (err) {
|
|
3905
4264
|
this.emit({
|
|
@@ -4063,6 +4422,8 @@ export class ClientSession {
|
|
|
4063
4422
|
this.files.unwatchDir();
|
|
4064
4423
|
this.files.unwatchGit();
|
|
4065
4424
|
this.webUi.dispose();
|
|
4425
|
+
// 关闭所有挂起的用户提问(dispose 时以「取消」解析,避免模型挂死)。
|
|
4426
|
+
this.cancelPendingQuestions();
|
|
4066
4427
|
this.bg.stop();
|
|
4067
4428
|
for (const conv of this.convs.values()) {
|
|
4068
4429
|
this.clearAllToolWatchdogs(conv);
|
|
@@ -4080,6 +4441,10 @@ export class AgentService {
|
|
|
4080
4441
|
cwd;
|
|
4081
4442
|
/** index.ts 注入:SDK 工具执行事件的插件转发钩子,attach 时拷贝到每个新会话。 */
|
|
4082
4443
|
onToolEvent = undefined;
|
|
4444
|
+
/** index.ts 注入:运行轨迹事件的插件转发钩子,attach 时拷贝到每个新会话。 */
|
|
4445
|
+
onRunEvent = undefined;
|
|
4446
|
+
/** index.ts 注入:对话切换通知钩子,attach 时拷贝到每个新会话。 */
|
|
4447
|
+
onConversationChanged = undefined;
|
|
4083
4448
|
/** index.ts 注入:读取插件当前注册的 AI 工具(attach 时拷贝到每个新会话)。 */
|
|
4084
4449
|
pluginToolsProvider = undefined;
|
|
4085
4450
|
/** index.ts 注入:读取插件当前注册的斜杠命令(attach 时拷贝到每个新会话)。 */
|
|
@@ -4142,6 +4507,21 @@ export class AgentService {
|
|
|
4142
4507
|
n += cs.pendingMessages();
|
|
4143
4508
|
return n;
|
|
4144
4509
|
}
|
|
4510
|
+
/** 插件用:全客户端最近活跃对话的快照(at 最大者即“当前打开的对话”)。 */
|
|
4511
|
+
readConversationForPlugins() {
|
|
4512
|
+
let best = null;
|
|
4513
|
+
for (const cs of this.clients.values()) {
|
|
4514
|
+
try {
|
|
4515
|
+
const s = cs.readConversationForPlugins();
|
|
4516
|
+
if (s && (!best || s.at > best.at))
|
|
4517
|
+
best = s;
|
|
4518
|
+
}
|
|
4519
|
+
catch {
|
|
4520
|
+
/* 单客户端坏了不影响其他 */
|
|
4521
|
+
}
|
|
4522
|
+
}
|
|
4523
|
+
return best;
|
|
4524
|
+
}
|
|
4145
4525
|
/** index.ts calls this when a browser socket opens/closes. */
|
|
4146
4526
|
noteSocketOpen() {
|
|
4147
4527
|
this.socketCount += 1;
|
|
@@ -4217,6 +4597,8 @@ export class AgentService {
|
|
|
4217
4597
|
// Forward hooks (set once by index.ts) to every session.
|
|
4218
4598
|
cs.onQuit = this.onQuit;
|
|
4219
4599
|
cs.onToolEvent = this.onToolEvent;
|
|
4600
|
+
cs.onRunEvent = this.onRunEvent;
|
|
4601
|
+
cs.onConversationChanged = () => this.onConversationChanged?.();
|
|
4220
4602
|
cs.pluginToolsProvider = this.pluginToolsProvider;
|
|
4221
4603
|
cs.pluginCommandsProvider = this.pluginCommandsProvider;
|
|
4222
4604
|
cs.pluginBgTasksProvider = this.pluginBgTasksProvider;
|