pi-web-ui 0.67.0 → 0.68.1

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.
@@ -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";
@@ -36,7 +36,7 @@ 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
38
  import { BUILTIN_SOUL, DEFAULT_PROMPT_TEMPLATE, renderPromptTemplate, resolveSectionTexts, } from "./prompt-composer.js";
39
- import { serializeMessage, serializeStreamingMessage } from "./serialize.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
@@ -296,6 +296,37 @@ function extractAssistantTextFromContent(content) {
296
296
  .join("\n");
297
297
  }
298
298
  export { workspacePath };
299
+ /** 轨迹事件 payload 封顶(可直接广播/持久化,不撑爆 storage.json)。 */
300
+ const RUN_TASK_CAP = 500;
301
+ const RUN_ARGS_CAP = 4000;
302
+ const RUN_RESULT_CAP = 4000;
303
+ function truncRun(s, cap) {
304
+ return s.length <= cap ? s : `${s.slice(0, cap)}\n… [truncated]`;
305
+ }
306
+ /** 从 SDK tool result 里抠可读文本预览(text 块拼接,图片/二进制占位,封顶)。 */
307
+ function previewToolResult(result) {
308
+ try {
309
+ const content = result?.content;
310
+ if (Array.isArray(content)) {
311
+ const parts = [];
312
+ for (const c of content) {
313
+ if (c && typeof c === "object" && c.type === "text") {
314
+ parts.push(String(c.text ?? ""));
315
+ }
316
+ else {
317
+ parts.push("[…]");
318
+ }
319
+ }
320
+ return truncRun(parts.join("\n"), RUN_RESULT_CAP);
321
+ }
322
+ if (typeof result === "string")
323
+ return truncRun(result, RUN_RESULT_CAP);
324
+ return truncRun(JSON.stringify(result ?? null), RUN_RESULT_CAP);
325
+ }
326
+ catch {
327
+ return "[unserializable result]";
328
+ }
329
+ }
299
330
  /** Hard cap on how long ONE tool call may run before the watchdog aborts the
300
331
  * session. The SDK bash tool has NO default timeout, so a command that never
301
332
  * finishes (servers, watchers, infinite loops) would otherwise hang the whole
@@ -494,6 +525,12 @@ export class ClientSession {
494
525
  /** index.ts 注入(经 AgentService 拷贝到每个新会话):把 SDK 工具执行事件转发给
495
526
  * 插件(PluginManager.emitToolEvent)。未设置时不做任何事。 */
496
527
  onToolEvent = undefined;
528
+ /** index.ts 注入:把运行轨迹事件转发给插件(PluginManager.emitRunEvent,
529
+ * 轨迹视图插件靠它聚合时间线)。未设置时不做任何事。 */
530
+ onRunEvent = undefined;
531
+ /** index.ts 注入:当前打开对话变了(切历史会话/切 running 对话/新对话)时
532
+ * 通知插件(PluginManager.emitConversationChanged)——轨迹视图靠它重拉。 */
533
+ onConversationChanged = undefined;
497
534
  /** index.ts 注入:读取插件当前注册的 AI 工具(attach 时拷贝到每个新会话)。 */
498
535
  pluginToolsProvider = undefined;
499
536
  /** index.ts 注入:读取插件当前注册的斜杠命令(目录展示 + prompt 拦截执行)。 */
@@ -726,6 +763,10 @@ export class ClientSession {
726
763
  /** 子代理最近一次运行的结局:最后一条 assistant 消息的 errorMessage / stopReason。
727
764
  * 报错 > 中止 > 正常,三者互斥;无 assistant 消息时返回空。 */
728
765
  subagentRunOutcome(conv) {
766
+ // 自动重试等待期结局未定:瞬时 error 不算失败,避免向主对话误报
767
+ // 「子代理运行失败」(耗尽后 auto_retry_end 清旗,真正失败照常通知)。
768
+ if (conv.retryState)
769
+ return {};
729
770
  try {
730
771
  const msgs = conv.session.agent.state.messages;
731
772
  for (let i = msgs.length - 1; i >= 0; i--) {
@@ -1456,6 +1497,76 @@ export class ClientSession {
1456
1497
  clearTimeout(t);
1457
1498
  conv.toolWatchdogs.clear();
1458
1499
  }
1500
+ /** 发一条运行轨迹事件给插件(host.onRunEvent 订阅者,如轨迹视图插件)。
1501
+ * 异常隔离——序列化/插件坏了只记日志,绝不影响主流程。 */
1502
+ emitRun(conv, ev) {
1503
+ if (!this.onRunEvent)
1504
+ return;
1505
+ try {
1506
+ this.onRunEvent({ ...ev, conversationId: conv.id, at: Date.now() });
1507
+ }
1508
+ catch (err) {
1509
+ console.error("[agent-service] onRunEvent failed:", err);
1510
+ }
1511
+ }
1512
+ /** 当前打开对话变了 → 通知插件重拉(切历史会话/切 running 对话/新对话)。
1513
+ * 异常隔离——插件坏了只记日志,绝不影响切换流程。 */
1514
+ notifyConversationChanged() {
1515
+ if (!this.onConversationChanged)
1516
+ return;
1517
+ try {
1518
+ this.onConversationChanged();
1519
+ }
1520
+ catch (err) {
1521
+ console.error("[agent-service] onConversationChanged failed:", err);
1522
+ }
1523
+ }
1524
+ /** 插件用:本客户端最近活跃对话的快照(轨迹视图直接显示打开对话的时间线)。
1525
+ * messages/streamingMessage 为引用稳定的只读缓存对象——调用方只读、不得修改。 */
1526
+ readConversationForPlugins() {
1527
+ try {
1528
+ let target = null;
1529
+ for (const c of this.convs.values()) {
1530
+ if (!target || c.lastActiveAt > target.lastActiveAt)
1531
+ target = c;
1532
+ }
1533
+ if (!target)
1534
+ return null;
1535
+ const state = target.session.agent.state;
1536
+ let stats = {
1537
+ totalMessages: 0,
1538
+ tokens: { input: 0, output: 0, total: 0 },
1539
+ cost: 0,
1540
+ };
1541
+ try {
1542
+ const s = target.session.getSessionStats();
1543
+ stats = { totalMessages: s.totalMessages, tokens: s.tokens, cost: s.cost };
1544
+ }
1545
+ catch {
1546
+ /* stats 尽力而为 */
1547
+ }
1548
+ let streamingMessage = null;
1549
+ try {
1550
+ streamingMessage = state.streamingMessage ? serializeStreamingMessage(state.streamingMessage) : null;
1551
+ }
1552
+ catch {
1553
+ /* 尽力而为 */
1554
+ }
1555
+ return {
1556
+ conversationId: target.id,
1557
+ title: target.title,
1558
+ at: target.lastActiveAt,
1559
+ isStreaming: target.session.isStreaming,
1560
+ messages: this.messagesOf(target),
1561
+ streamingMessage,
1562
+ stats,
1563
+ };
1564
+ }
1565
+ catch (err) {
1566
+ console.error("[agent-service] readConversationForPlugins failed:", err);
1567
+ return null;
1568
+ }
1569
+ }
1459
1570
  onEvent(conv, event) {
1460
1571
  // Any SDK event proves the run is alive — feeds the stall watchdog below.
1461
1572
  conv.lastSdkEventAt = Date.now();
@@ -1485,7 +1596,26 @@ export class ClientSession {
1485
1596
  }
1486
1597
  this.armToolWatchdog(conv, event.toolCallId);
1487
1598
  // 插件扩展点:工具开始执行(异常由 emitToolEvent 隔离)。
1488
- this.onToolEvent?.({ phase: "start", toolName: event.toolName, conversationId: conv.id });
1599
+ this.onToolEvent?.({
1600
+ phase: "start",
1601
+ toolName: event.toolName,
1602
+ conversationId: conv.id,
1603
+ toolCallId: event.toolCallId,
1604
+ });
1605
+ // 轨迹事件:带参数预览(JSON 封顶;超大参数只记截断)。
1606
+ let argsText = "null";
1607
+ try {
1608
+ argsText = truncRun(JSON.stringify(event.args ?? null), RUN_ARGS_CAP);
1609
+ }
1610
+ catch {
1611
+ argsText = "[unserializable args]";
1612
+ }
1613
+ this.emitRun(conv, {
1614
+ type: "tool_start",
1615
+ toolCallId: event.toolCallId,
1616
+ toolName: event.toolName,
1617
+ argsText,
1618
+ });
1489
1619
  break;
1490
1620
  }
1491
1621
  case "tool_execution_end": {
@@ -1502,6 +1632,16 @@ export class ClientSession {
1502
1632
  phase: "end",
1503
1633
  toolName: event.toolName,
1504
1634
  conversationId: conv.id,
1635
+ toolCallId: event.toolCallId,
1636
+ ...(durationMs !== undefined ? { durationMs } : {}),
1637
+ isError: event.isError,
1638
+ });
1639
+ // 轨迹事件:带结果预览(封顶)+ 耗时/错误标志。
1640
+ this.emitRun(conv, {
1641
+ type: "tool_end",
1642
+ toolCallId: event.toolCallId,
1643
+ toolName: event.toolName,
1644
+ resultText: previewToolResult(event.result),
1505
1645
  ...(durationMs !== undefined ? { durationMs } : {}),
1506
1646
  isError: event.isError,
1507
1647
  });
@@ -1597,9 +1737,58 @@ export class ClientSession {
1597
1737
  }
1598
1738
  break;
1599
1739
  }
1740
+ case "auto_retry_start": {
1741
+ // 大模型 API 瞬时报错,SDK 退避重试:填实重试信息。末尾 error
1742
+ // 消息已被(或即将被)SDK 从 state 摘掉,currentMessages() 凭此旗
1743
+ // 过滤,快照只显示温和的重试条。落盘由底部检查点立即 flush。
1744
+ conv.retryState = {
1745
+ attempt: event.attempt,
1746
+ maxAttempts: event.maxAttempts,
1747
+ delayMs: event.delayMs,
1748
+ errorMessage: event.errorMessage,
1749
+ };
1750
+ break;
1751
+ }
1752
+ case "auto_retry_end": {
1753
+ // 重试结束:成功 → 新内容照常显示;耗尽 → error 消息留驻,
1754
+ // 快照永久标红。落盘由底部检查点立即 flush。
1755
+ conv.retryState = null;
1756
+ break;
1757
+ }
1600
1758
  // A run finished or a new entry was persisted — keep the session list fresh
1601
1759
  // (new chat + first message, completed turns, compaction, etc.).
1602
1760
  case "agent_end": {
1761
+ // 可重试错误:SDK 随后发 auto_retry_start 并把末尾 error 消息从
1762
+ // state 摘掉。这里先立占位,让本次立即 flush 的快照就不含瞬时红错
1763
+ // ——否则快照先画红、摘掉后又消失,即「红色报错一闪而过」。
1764
+ if (event.willRetry) {
1765
+ let errorMessage = "";
1766
+ for (let i = event.messages.length - 1; i >= 0; i--) {
1767
+ const m = event.messages[i];
1768
+ if (m.role === "assistant" && typeof m.errorMessage === "string") {
1769
+ errorMessage = m.errorMessage;
1770
+ break;
1771
+ }
1772
+ }
1773
+ conv.retryState = { attempt: 0, maxAttempts: 0, delayMs: 0, errorMessage };
1774
+ }
1775
+ else {
1776
+ // 本轮结束且无后续重试:任何残留占位都是过期的(会话替换、
1777
+ // 结束信号丢失等),清掉,否则横幅会卡住不消失。
1778
+ conv.retryState = null;
1779
+ }
1780
+ // 轨迹事件:本轮结束(放最前——aborted 中断路径也会 break,
1781
+ // 轨迹里必须留下「已停止」而不是凭空消失)。
1782
+ try {
1783
+ const lastAssistant = [...event.messages].reverse().find((m) => {
1784
+ const a = m;
1785
+ return a.role === "assistant" && typeof a.stopReason === "string";
1786
+ });
1787
+ this.emitRun(conv, lastAssistant?.stopReason ? { type: "run_end", stopReason: lastAssistant.stopReason } : { type: "run_end" });
1788
+ }
1789
+ catch {
1790
+ /* 轨迹尽力而为 */
1791
+ }
1603
1792
  this.scheduleSessionsRefresh();
1604
1793
  this.refreshConversationTitle(conv);
1605
1794
  // 内联标记不在此兜底扫最后一条 assistant:每条气泡结束已走 message_end
@@ -1659,16 +1848,47 @@ export class ClientSession {
1659
1848
  break;
1660
1849
  }
1661
1850
  case "message_end": {
1851
+ // 轨迹事件:一条消息定稿(user/assistant 都收;custom display:false
1852
+ // 的 serializeMessage 返回 null 时跳过)。
1853
+ try {
1854
+ const ui = serializeMessage(event.message, 0);
1855
+ if (ui)
1856
+ this.emitRun(conv, { type: "message", message: ui });
1857
+ }
1858
+ catch {
1859
+ /* 轨迹尽力而为 */
1860
+ }
1861
+ // 每条 assistant 气泡流式结束 → 立即解析其中的内联标记:每个气泡各自
1662
1862
  // 每条 assistant 气泡流式结束 → 立即解析其中的内联标记:每个气泡各自
1663
1863
  // 生效(不再等整轮 agent_end),同一轮里先前消息的标记也不再丢。
1664
1864
  const mm = event.message;
1665
1865
  if (mm?.role !== "assistant")
1666
1866
  break;
1867
+ // 非 error 的 assistant 定稿 = 重试周期结束(与 SDK 重置
1868
+ // _retryAttempt 的条件一致):即使 auto_retry_end 丢失,横幅也不会卡住。
1869
+ if (mm.stopReason !== "error")
1870
+ conv.retryState = null;
1667
1871
  const text = extractAssistantTextFromContent(mm.content);
1668
1872
  if (text && text.includes("[["))
1669
1873
  void this.markerSvc.handleAssistantText(conv.id, text);
1670
1874
  break;
1671
1875
  }
1876
+ case "agent_start": {
1877
+ // 轨迹事件:新一轮开始(任务文本由 prompt() 暂存;steer/内部续跑
1878
+ // 无暂存时省略,插件回退为「继续执行」)。
1879
+ const task = conv.pendingTask;
1880
+ conv.pendingTask = undefined;
1881
+ this.emitRun(conv, task ? { type: "run_start", task } : { type: "run_start" });
1882
+ break;
1883
+ }
1884
+ case "turn_start": {
1885
+ this.emitRun(conv, { type: "turn_start" });
1886
+ break;
1887
+ }
1888
+ case "turn_end": {
1889
+ this.emitRun(conv, { type: "turn_end" });
1890
+ break;
1891
+ }
1672
1892
  case "message_update": {
1673
1893
  // Live assistant-message increment, deliberately OUTSIDE the snapshot
1674
1894
  // channel: send() drops snapshots under backpressure (big sessions),
@@ -1714,7 +1934,11 @@ export class ClientSession {
1714
1934
  // Snapshot checkpoint policy: deltas carry live rendering during streaming;
1715
1935
  // full snapshots are reconciliation checkpoints taken immediately at
1716
1936
  // run/tool boundaries and on a slow timer otherwise.
1717
- if (event.type === "agent_end" || event.type === "tool_execution_end" || event.type === "compaction_end") {
1937
+ if (event.type === "agent_end" ||
1938
+ event.type === "tool_execution_end" ||
1939
+ event.type === "compaction_end" ||
1940
+ event.type === "auto_retry_start" ||
1941
+ event.type === "auto_retry_end") {
1718
1942
  this.flushSnapshot();
1719
1943
  }
1720
1944
  else {
@@ -1751,7 +1975,10 @@ export class ClientSession {
1751
1975
  }
1752
1976
  /** Serialize a persisted message with a STABLE id + cached object reference. */
1753
1977
  serializeCached(m) {
1754
- const conv = this.conv;
1978
+ return this.serializeCachedFor(this.conv, m);
1979
+ }
1980
+ /** serializeCached 的按对话版本(插件快照读非活跃对话用;缓存仍按对话隔离)。 */
1981
+ serializeCachedFor(conv, m) {
1755
1982
  // toolResult messages are keyed by toolCallId; everything else by
1756
1983
  // role+timestamp. A single prompt can emit several same-role messages
1757
1984
  // within the SAME millisecond (multiple attachment asides), so the
@@ -1801,10 +2028,17 @@ export class ClientSession {
1801
2028
  * Element objects are reference-stable (serializeCached cache), which is
1802
2029
  * what lets emitSnapshotNow detect append-only growth via identity walk. */
1803
2030
  currentMessages() {
1804
- const conv = this.conv;
1805
- const rawMessages = conv.session.agent.state.messages
1806
- .map((m) => this.serializeCached(m))
2031
+ return this.messagesOf(this.conv);
2032
+ }
2033
+ /** currentMessages 的按对话版本(插件快照读非活跃对话用)。 */
2034
+ messagesOf(conv) {
2035
+ let rawMessages = conv.session.agent.state.messages
2036
+ .map((m) => this.serializeCachedFor(conv, m))
1807
2037
  .filter((m) => m !== null);
2038
+ // 自动重试等待期:SDK 暂留在 state 末尾的 error 气泡只是中间态(随后被
2039
+ // 摘掉重跑),不进快照——成功则用户永远看不到,耗尽才标红。否则 agent_end
2040
+ // 的立即 flush 会先画红、摘掉后又消失(红色一闪而过)。
2041
+ rawMessages = stripTransientRetryErrors(rawMessages, !!conv.retryState);
1808
2042
  // Reuse the previous array when nothing changed: the element objects are
1809
2043
  // cached (reference-stable) anyway, and a stable array reference lets the
1810
2044
  // frontend memoize derived maps instead of rebuilding them every 60ms.
@@ -1843,6 +2077,13 @@ export class ClientSession {
1843
2077
  catch {
1844
2078
  // stats are best-effort
1845
2079
  }
2080
+ // 流式 error 同样是中间态(定稿走 message_end/agent_end):先藏起
2081
+ // errorMessage,避免红色在 streaming 气泡里闪一下。最终失败会经由
2082
+ // messages 永久标红,不影响告警。
2083
+ let streamingMessage = state.streamingMessage ? serializeStreamingMessage(state.streamingMessage) : null;
2084
+ if (streamingMessage?.stopReason === "error") {
2085
+ streamingMessage = { ...streamingMessage, errorMessage: undefined };
2086
+ }
1846
2087
  return {
1847
2088
  clientId: this.clientId,
1848
2089
  cwd: this.cwd,
@@ -1850,11 +2091,7 @@ export class ClientSession {
1850
2091
  sessionFile: this.session.sessionFile,
1851
2092
  conversationId: this.activeId,
1852
2093
  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,
2094
+ streamingMessage,
1858
2095
  isStreaming: this.session.isStreaming,
1859
2096
  model: model
1860
2097
  ? {
@@ -1870,6 +2107,7 @@ export class ClientSession {
1870
2107
  availableThinkingLevels: this.session.getAvailableThinkingLevels(),
1871
2108
  queue: { steering: conv.queueSteering, followUp: conv.queueFollowUp },
1872
2109
  errorMessage: state.errorMessage,
2110
+ retry: conv.retryState ?? null,
1873
2111
  tools: state.tools.map((t) => t.name),
1874
2112
  version: ++this.version,
1875
2113
  piConfigured: this.isPiConfigured(),
@@ -2607,6 +2845,9 @@ export class ClientSession {
2607
2845
  // is refused until admission reopens.
2608
2846
  if (this.quiesceBlocked())
2609
2847
  return;
2848
+ // 轨迹用:暂存本轮任务文本,下一轮 agent_start 消费(steer/内部续跑
2849
+ // 不经此处,届时 task 缺省,插件回退为「继续执行」)。
2850
+ conv.pendingTask = text.trim() ? truncRun(text.trim(), RUN_TASK_CAP) : undefined;
2610
2851
  // Name the conversation from its FIRST prompt immediately, before any
2611
2852
  // await: the typed text IS the name. The `conv` reference was captured
2612
2853
  // before the try block, so a concurrent switch/new_chat while prompt()
@@ -3000,6 +3241,8 @@ export class ClientSession {
3000
3241
  // The new runtime re-discovered skills/templates — refresh the catalog
3001
3242
  // so the picker stops showing the previous runtime's list.
3002
3243
  void this.pushSlashCommands();
3244
+ // 新对话即当前打开 → 插件重拉(轨迹视图跟随)。
3245
+ this.notifyConversationChanged();
3003
3246
  }
3004
3247
  catch (err) {
3005
3248
  this.emit({
@@ -3115,6 +3358,8 @@ export class ClientSession {
3115
3358
  void this.listFiles(undefined);
3116
3359
  void this.listCommands();
3117
3360
  }
3361
+ // 当前打开对话变了 → 插件重拉(轨迹视图切会话后即刷新,不等轮询)。
3362
+ this.notifyConversationChanged();
3118
3363
  this.flushSnapshot();
3119
3364
  }
3120
3365
  /** Push every running conversation across ALL projects to the client. The
@@ -3565,6 +3810,8 @@ export class ClientSession {
3565
3810
  this.pushTerminals();
3566
3811
  // The restored conversation has a fresh project-bound resource cache.
3567
3812
  void this.pushSlashCommands();
3813
+ // 切历史会话成功 → 插件重拉(轨迹视图立即显示该会话时间线)。
3814
+ this.notifyConversationChanged();
3568
3815
  }
3569
3816
  catch (err) {
3570
3817
  openedTerminals?.killAll();
@@ -3812,10 +4059,25 @@ export class ClientSession {
3812
4059
  }
3813
4060
  async setCwd(newCwd) {
3814
4061
  try {
3815
- const { resolve } = await import("node:path");
4062
+ const { resolve, sep } = await import("node:path");
3816
4063
  this.files.unwatchGit(); // stale repo's watcher must not fire across projects
3817
4064
  const fs = await import("node:fs/promises");
3818
- const abs = resolve(newCwd);
4065
+ const trimmed = newCwd.trim();
4066
+ if (trimmed === MACHINE_ROOT) {
4067
+ // 机器根是虚拟层(盘符列表),不能作工作目录——指引用户选具体目录。
4068
+ this.emit({
4069
+ type: "notice",
4070
+ level: "warning",
4071
+ text: "请选择一个具体目录作为工作目录(此电脑本身不是目录)",
4072
+ textEn: "Pick a concrete directory as the workspace (This PC itself is not a directory)",
4073
+ });
4074
+ return;
4075
+ }
4076
+ // Windows 裸盘符("C:"):resolve 会按该盘当前目录解析,必须显式指到盘根;
4077
+ // 仅 win32 生效——posix 下 "C:" 仍是普通相对路径,避免误伤同名目录。
4078
+ const abs = process.platform === "win32" && /^[A-Za-z]:$/.test(trimmed)
4079
+ ? `${trimmed.toUpperCase()}${sep}`
4080
+ : resolve(trimmed);
3819
4081
  const st = await fs.stat(abs);
3820
4082
  if (!st.isDirectory()) {
3821
4083
  throw new Error("路径不是目录");
@@ -3900,6 +4162,8 @@ export class ClientSession {
3900
4162
  void this.listFiles(undefined);
3901
4163
  // Commands are per-project (.pi/commands.json in the current cwd).
3902
4164
  void this.listCommands();
4165
+ // 切项目即换了当前打开对话 → 插件重拉。
4166
+ this.notifyConversationChanged();
3903
4167
  }
3904
4168
  catch (err) {
3905
4169
  this.emit({
@@ -4080,6 +4344,10 @@ export class AgentService {
4080
4344
  cwd;
4081
4345
  /** index.ts 注入:SDK 工具执行事件的插件转发钩子,attach 时拷贝到每个新会话。 */
4082
4346
  onToolEvent = undefined;
4347
+ /** index.ts 注入:运行轨迹事件的插件转发钩子,attach 时拷贝到每个新会话。 */
4348
+ onRunEvent = undefined;
4349
+ /** index.ts 注入:对话切换通知钩子,attach 时拷贝到每个新会话。 */
4350
+ onConversationChanged = undefined;
4083
4351
  /** index.ts 注入:读取插件当前注册的 AI 工具(attach 时拷贝到每个新会话)。 */
4084
4352
  pluginToolsProvider = undefined;
4085
4353
  /** index.ts 注入:读取插件当前注册的斜杠命令(attach 时拷贝到每个新会话)。 */
@@ -4142,6 +4410,21 @@ export class AgentService {
4142
4410
  n += cs.pendingMessages();
4143
4411
  return n;
4144
4412
  }
4413
+ /** 插件用:全客户端最近活跃对话的快照(at 最大者即“当前打开的对话”)。 */
4414
+ readConversationForPlugins() {
4415
+ let best = null;
4416
+ for (const cs of this.clients.values()) {
4417
+ try {
4418
+ const s = cs.readConversationForPlugins();
4419
+ if (s && (!best || s.at > best.at))
4420
+ best = s;
4421
+ }
4422
+ catch {
4423
+ /* 单客户端坏了不影响其他 */
4424
+ }
4425
+ }
4426
+ return best;
4427
+ }
4145
4428
  /** index.ts calls this when a browser socket opens/closes. */
4146
4429
  noteSocketOpen() {
4147
4430
  this.socketCount += 1;
@@ -4217,6 +4500,8 @@ export class AgentService {
4217
4500
  // Forward hooks (set once by index.ts) to every session.
4218
4501
  cs.onQuit = this.onQuit;
4219
4502
  cs.onToolEvent = this.onToolEvent;
4503
+ cs.onRunEvent = this.onRunEvent;
4504
+ cs.onConversationChanged = () => this.onConversationChanged?.();
4220
4505
  cs.pluginToolsProvider = this.pluginToolsProvider;
4221
4506
  cs.pluginCommandsProvider = this.pluginCommandsProvider;
4222
4507
  cs.pluginBgTasksProvider = this.pluginBgTasksProvider;
@@ -454,9 +454,9 @@ export async function buildAttachmentMessages(ctx, attachments) {
454
454
  content: [
455
455
  {
456
456
  type: "text",
457
- text: `
458
- <vision-bridge>
459
- ${transcript}
457
+ text: `
458
+ <vision-bridge>
459
+ ${transcript}
460
460
  </vision-bridge>`,
461
461
  },
462
462
  ...(pathImg
@@ -211,6 +211,8 @@ export class ClientStateStore {
211
211
  visionBridgePromptMode: stored?.visionBridgePromptMode === "replace" ? "replace" : "append",
212
212
  visionBridgePrompt: stored?.visionBridgePrompt ?? "",
213
213
  subagentDefaultModel: stored?.subagentDefaultModel ?? null,
214
+ quickPhrases: stored?.quickPhrases ?? [],
215
+ quickPhrasesEnabled: stored?.quickPhrasesEnabled ?? true,
214
216
  reviewPrompt: stored?.reviewPrompt ?? "",
215
217
  reviewDisabledSkills: stored?.reviewDisabledSkills ?? [],
216
218
  disabledPlugins: stored?.disabledPlugins ?? [],
@@ -242,6 +244,8 @@ export class ClientStateStore {
242
244
  reviewPrompt: settings.reviewPrompt ?? cur.reviewPrompt ?? "",
243
245
  reviewDisabledSkills: settings.reviewDisabledSkills ?? cur.reviewDisabledSkills ?? [],
244
246
  disabledPlugins: settings.disabledPlugins ?? cur.disabledPlugins ?? [],
247
+ quickPhrases: settings.quickPhrases ?? cur.quickPhrases ?? [],
248
+ quickPhrasesEnabled: settings.quickPhrasesEnabled ?? cur.quickPhrasesEnabled ?? true,
245
249
  };
246
250
  this.save();
247
251
  }
@@ -112,6 +112,8 @@ const DEFAULT_SETTINGS = {
112
112
  toolsWrap: true,
113
113
  disabledPlugins: [],
114
114
  reviewPrompt: "",
115
+ quickPhrases: [],
116
+ quickPhrasesEnabled: true,
115
117
  };
116
118
  // ---------------------------------------------------------------------------
117
119
  // DshClientSession — 一个浏览器客户端
@@ -223,6 +225,8 @@ export class DshClientSession {
223
225
  toolsWrap: savedSettings.toolsWrap,
224
226
  disabledPlugins: savedSettings.disabledPlugins ?? [],
225
227
  reviewPrompt: savedSettings.reviewPrompt,
228
+ quickPhrases: savedSettings.quickPhrases ?? [],
229
+ quickPhrasesEnabled: savedSettings.quickPhrasesEnabled ?? true,
226
230
  };
227
231
  }
228
232
  // 第一个 conversation = 新会话(每客户端独立 sessionId,避免多标签页/多
@@ -2082,6 +2086,8 @@ export class DshClientSession {
2082
2086
  subagentDefaultTemplates: [],
2083
2087
  subagentDefaultModel: null,
2084
2088
  subagentModels: [],
2089
+ quickPhrases: [...this.settings.quickPhrases],
2090
+ quickPhrasesEnabled: this.settings.quickPhrasesEnabled,
2085
2091
  };
2086
2092
  this.emit({ type: "settings_state", settings });
2087
2093
  }
@@ -2110,6 +2116,15 @@ export class DshClientSession {
2110
2116
  this.settings.disabledPlugins = partial.disabledPlugins;
2111
2117
  if (partial.reviewPrompt !== undefined)
2112
2118
  this.settings.reviewPrompt = partial.reviewPrompt;
2119
+ if (partial.quickPhrases !== undefined) {
2120
+ const seen = new Set();
2121
+ this.settings.quickPhrases = (Array.isArray(partial.quickPhrases) ? partial.quickPhrases : [])
2122
+ .map((p) => String(p).trim().slice(0, 200))
2123
+ .filter((p) => p && !seen.has(p) && (seen.add(p), true))
2124
+ .slice(0, 30);
2125
+ }
2126
+ if (partial.quickPhrasesEnabled !== undefined)
2127
+ this.settings.quickPhrasesEnabled = partial.quickPhrasesEnabled;
2113
2128
  // 持久化(跨重连存活)。
2114
2129
  this.stateStore.saveSettings(this.clientId, {
2115
2130
  promptMode: this.settings.promptMode,
@@ -2124,6 +2139,8 @@ export class DshClientSession {
2124
2139
  toolsWrap: this.settings.toolsWrap,
2125
2140
  disabledPlugins: this.settings.disabledPlugins,
2126
2141
  reviewPrompt: this.settings.reviewPrompt,
2142
+ quickPhrases: this.settings.quickPhrases,
2143
+ quickPhrasesEnabled: this.settings.quickPhrasesEnabled,
2127
2144
  });
2128
2145
  // 仅系统提示词变化才重启运行时(DSH_PERSONA 由 launcher env 注入);
2129
2146
  // 其他设置(开关/隐藏插件等)只存不回写运行时。
@@ -1 +1 @@
1
- []
1
+ []