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.
Files changed (35) hide show
  1. package/bin/pi-web-ui.mjs +1876 -1876
  2. package/dist/server/agent-service.js +450 -68
  3. package/dist/server/attachments.js +3 -3
  4. package/dist/server/client-state.js +4 -2
  5. package/dist/server/dsh/dsh-agent-service.js +15 -1
  6. package/dist/server/dsh/runtime/cordis.yml +1 -1
  7. package/dist/server/dsh/runtime/goal-rpc.mjs +645 -645
  8. package/dist/server/dsh/runtime/launcher.mjs +164 -164
  9. package/dist/server/dsh/runtime/override.patch.yml +71 -71
  10. package/dist/server/dsh/runtime/runtime-root.mjs +92 -86
  11. package/dist/server/index.js +68 -2
  12. package/dist/server/locales.js +156 -0
  13. package/dist/server/plugins.js +63 -0
  14. package/dist/server/prompt-composer.js +34 -0
  15. package/dist/server/serialize.js +24 -0
  16. package/dist/server/settings-service.js +9 -0
  17. package/dist/server/update-check.js +49 -50
  18. package/dist/server/vision-bridge.js +9 -9
  19. package/extensions/webui.ts +190 -190
  20. package/package.json +6 -2
  21. package/plugins/catalog.json +9 -0
  22. package/web/dist/assets/TerminalPanel-BQ5NTB9Y.js +6 -0
  23. package/web/dist/assets/TerminalPanel-DOrYoP_4.css +32 -0
  24. package/web/dist/assets/index-C_I-6Zul.css +10 -0
  25. package/web/dist/assets/index-Ck5pa3XK.js +333 -0
  26. package/web/dist/assets/markdown-DOsihKaR.js +51 -0
  27. package/web/dist/assets/{react-C9ovnpIm.js → react-DIP6JKYk.js} +2 -2
  28. package/web/dist/assets/xterm-B96xOxS9.js +38 -0
  29. package/web/dist/index.html +4 -4
  30. package/web/dist/assets/TerminalPanel-6GBZ9nXN.css +0 -32
  31. package/web/dist/assets/TerminalPanel-CTY_LN4U.js +0 -6
  32. package/web/dist/assets/index-Dc6t3GTo.css +0 -10
  33. package/web/dist/assets/index-fwF-jmiV.js +0 -332
  34. package/web/dist/assets/markdown-DRBrS2Nf.js +0 -51
  35. 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?.({ phase: "start", toolName: event.toolName, conversationId: conv.id });
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" || event.type === "tool_execution_end" || event.type === "compaction_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
- const conv = this.conv;
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
- const conv = this.conv;
1805
- const rawMessages = conv.session.agent.state.messages
1806
- .map((m) => this.serializeCached(m))
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
- // The in-progress assistant message lives in state.streamingMessage
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 runs ASYNCHRONOUSLY in the background: this getter serves the
1954
- * last known value and never blocks the event loop. It previously used
1955
- * spawnSync here, which could deadlock the whole server on Android/Termux:
1956
- * fork() inside a multi-threaded process (websocket handlers + snapshot
1957
- * serialization are active) occasionally left the forked child stuck
1958
- * between fork and exec (futex_wait) while the blocked main thread sat in
1959
- * spawnSync's pipe_read — the server kept running but stopped accepting
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.refreshPiCliProbe();
1971
- return cached?.installed ?? false;
2334
+ const installed = ClientSession.piCliOnPath();
2335
+ ClientSession.piCliProbe = { at: now, installed };
2336
+ return installed;
1972
2337
  }
1973
- static refreshPiCliProbe() {
1974
- if (ClientSession.piCliProbePending)
1975
- return;
1976
- ClientSession.piCliProbePending = true;
1977
- let proc;
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
- catch {
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 abs = resolve(newCwd);
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;