pi-web-ui 0.86.2 → 0.87.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.
Files changed (37) hide show
  1. package/CHANGELOG.md +84 -23
  2. package/README.md +1 -1
  3. package/bin/pi-web-ui.mjs +312 -40
  4. package/dist/server/agent-service.js +377 -25
  5. package/dist/server/client-state.js +8 -0
  6. package/dist/server/composer-drafts.js +138 -0
  7. package/dist/server/dsh/dsh-agent-service.js +535 -54
  8. package/dist/server/dsh/dsh-client.js +28 -0
  9. package/dist/server/dsh/dsh-sessions.js +28 -0
  10. package/dist/server/dsh/dsh-usage.js +82 -0
  11. package/dist/server/dsh/preset-clones.js +260 -0
  12. package/dist/server/dsh/runtime/custom-prompt.mjs +33 -0
  13. package/dist/server/dsh/runtime/goal-rpc.mjs +406 -22
  14. package/dist/server/dsh/runtime/launcher.mjs +17 -0
  15. package/dist/server/dsh/runtime/override.patch.yml +19 -1
  16. package/dist/server/files-service.js +259 -1
  17. package/dist/server/index.js +244 -5
  18. package/dist/server/mcp-bridge.js +126 -22
  19. package/dist/server/mcp-hot-reload.js +117 -0
  20. package/dist/server/model-admin.js +93 -3
  21. package/dist/server/plugin-dom.js +83 -0
  22. package/dist/server/plugin-facilities.js +15 -1
  23. package/dist/server/plugin-installer.js +6 -0
  24. package/dist/server/plugins.js +781 -74
  25. package/dist/server/protocol-version.js +1 -1
  26. package/dist/server/provider-oauth-flow.js +157 -0
  27. package/dist/server/update-check.js +22 -0
  28. package/package.json +1 -1
  29. package/plugins/catalog.json +9 -0
  30. package/themes/dark-teal.css +65 -22
  31. package/web/dist/assets/index-8xCnPMZP.js +374 -0
  32. package/web/dist/assets/index-F86qWlJy.css +41 -0
  33. package/web/dist/index.html +3 -2
  34. package/web/dist/assets/TerminalPanel-BytY8dx7.js +0 -6
  35. package/web/dist/assets/TerminalPanel-DOrYoP_4.css +0 -32
  36. package/web/dist/assets/index-CKoVyDkP.css +0 -10
  37. package/web/dist/assets/index-Dmwji4Cr.js +0 -361
@@ -18,12 +18,13 @@ import { spawn } from "node:child_process";
18
18
  import { randomUUID } from "node:crypto";
19
19
  import { createRequire } from "node:module";
20
20
  import { appendFileSync, existsSync, readFileSync, rmSync, statSync, mkdirSync, watch, writeFileSync } from "node:fs";
21
+ import { homedir } from "node:os";
21
22
  import { basename, delimiter, dirname, join, resolve, sep } from "node:path";
22
23
  import { fileURLToPath } from "node:url";
23
24
  import { createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, createBashToolDefinition, createLocalBashOperations, getAgentDir, SessionManager, VERSION, } from "@earendil-works/pi-coding-agent";
24
25
  import { Type } from "typebox";
25
26
  import { BgServerTracker } from "./bg-servers.js";
26
- import { checkAll as checkAllUpdates, collectTargets, compareVersions as compareSemver, resolveNpmRegistry, } from "./update-check.js";
27
+ import { checkAll as checkAllUpdates, collectTargets, compareVersions as compareSemver, resolveNpmRegistry, sortUpdateItems, } from "./update-check.js";
27
28
  import { hasActiveSubagentRun, hasPendingWaitSubscription, shouldRetainActive } from "./wait-subscription-scan.js";
28
29
  import { removeFirstOccurrence } from "./queue-utils.js";
29
30
  import { syncPluginToolsIntoSession } from "./plugins.js";
@@ -32,10 +33,11 @@ import { GoalService } from "./goal-service.js";
32
33
  import { MarkerService } from "./marker-service.js";
33
34
  import { SlashCommandsService, parseSlash } from "./slash-commands.js";
34
35
  import { ModelAdminService } from "./model-admin.js";
35
- import { FilesService, MACHINE_ROOT, workspacePath } from "./files-service.js";
36
+ import { FilesService, MACHINE_ROOT, desktopDirWire, workspacePath } from "./files-service.js";
36
37
  import { isExtensionDisabled, isExtensionEnabled, normalizeRetryMaxAttempts, normalizeSkillList, ClientStateStore, } from "./client-state.js";
37
38
  import { bilingual, pick, resolveServerLang } from "./i18n.js";
38
39
  import { SubagentTemplatesStore, pickTemplatePrompt } from "./subagent-templates.js";
40
+ import { ComposerDraftsStore } from "./composer-drafts.js";
39
41
  import { applyHeadTail, makePersistentTerminalTools, makeTerminalBashTool, stripAnsi, TERMINAL_TOOLS_GUIDANCE, } from "./terminals.js";
40
42
  import { applyAgentToolsGating, ASK_USER_QUESTION_TOOL_NAME, BROWSER_PAGE_TOOL_NAME, effectiveDisabledAgentTools, isTerminalGuidanceOn, MARKERS_LIST_TOOL_NAME, } from "./tool-manager.js";
41
43
  import { WebUIContext } from "./webui-context.js";
@@ -50,6 +52,12 @@ import { launchOrigin, toServiceInfo } from "./launch-origin.js";
50
52
  import { serializeMessage, serializeStreamingMessage, stripTransientRetryErrors, } from "./serialize.js";
51
53
  import { loadCommands, saveCommandsFile, TerminalManager } from "./terminals.js";
52
54
  const SNAPSHOT_INTERVAL_MS = 60;
55
+ /** 服务进程所在机器的用户主目录(wire 格式):进程内不变,模块加载时求值一次,
56
+ * 快照热路径直接引用(右栏 🏠 一键直达,见 protocol.ts 的 UiState.homeDir)。 */
57
+ const HOME_WIRE = homedir().replace(/\\/g, "/");
58
+ /** 桌面目录(wire 格式):进程内不变(见 files-service.ts 的 desktopDirWire),
59
+ * 不存在则空串 → 前端不渲染 🖥️。 */
60
+ const DESKTOP_WIRE = desktopDirWire(HOME_WIRE);
53
61
  /** While assistant deltas are flowing, live rendering is carried by
54
62
  * message_delta — full snapshots become pure reconciliation checkpoints, so
55
63
  * send them on a slow event-driven cadence (see flushSnapshot call-sites:
@@ -962,6 +970,10 @@ export class ClientSession {
962
970
  conv.listed = true;
963
971
  conv.title = subagentTitle(prompt);
964
972
  this.convs.set(conv.id, conv);
973
+ // 子代理会话同样订阅 SDK 事件:否则 onEvent 永不触发,点开查看时没有
974
+ // message_delta 流式增量、快照也不刷新,只能靠切走切回时的 flushSnapshot
975
+ // 看到新内容(dismiss/释放流程本来就会 unsubscribe,不泄漏)。
976
+ conv.unsubscribe = conv.session.subscribe((event) => this.onEvent(conv, event));
965
977
  // 子代理不走 bindSession——这里同样注入面板的重试次数覆盖。
966
978
  this.applyRetryOverrides();
967
979
  // 扩展绑定(rpc 模式);用 headless 的 Web UI context:
@@ -1368,6 +1380,8 @@ export class ClientSession {
1368
1380
  watchTimer = null;
1369
1381
  /** 子代理模板库(全局共享,<dataDir>/subagent-templates.json)。 */
1370
1382
  subagentTemplates;
1383
+ /** 未发送输入框草稿(全局共享,<dataDir>/composer-drafts.json,按 sessionId 键入,见 server/composer-drafts.ts)。 */
1384
+ drafts;
1371
1385
  /** 内置标记服务(todo/notify/svc/rename 等,可全局/分组开关)。 */
1372
1386
  markerSvc;
1373
1387
  // -----------------------------------------------------------------------
@@ -1401,6 +1415,7 @@ export class ClientSession {
1401
1415
  this.stateStore = stateStore;
1402
1416
  this.roots = stateStore.getWorkspaceRoots(clientId, cwd);
1403
1417
  this.subagentTemplates = new SubagentTemplatesStore(join(stateStore.dataDir, "subagent-templates.json"));
1418
+ this.drafts = new ComposerDraftsStore(join(stateStore.dataDir, "composer-drafts.json"));
1404
1419
  this.markerSvc = new MarkerService({
1405
1420
  clientId,
1406
1421
  stateStore,
@@ -1484,6 +1499,7 @@ export class ClientSession {
1484
1499
  this.piCheckCache = null;
1485
1500
  },
1486
1501
  pushModels: async () => this.listModels(),
1502
+ onOAuthActivated: (provider) => this.stateStore.deleteProviderEverywhere(provider),
1487
1503
  });
1488
1504
  // Prune dead background tasks every 30s (only spawns netstat/lsof while
1489
1505
  // the list is non-empty). unref: must not keep the process alive.
@@ -1784,26 +1800,78 @@ export class ClientSession {
1784
1800
  };
1785
1801
  }
1786
1802
  /** Summaries of conversations currently streaming — captured at shutdown
1787
- * so the next attach can tell the user their run was interrupted. */
1803
+ * so the next attach can reopen them and continue their runs. */
1788
1804
  streamingSummaries() {
1789
1805
  const out = [];
1790
1806
  for (const conv of this.convs.values()) {
1791
- if (conv.session.isStreaming)
1792
- out.push({ title: conv.title, cwd: conv.cwd });
1807
+ if (!conv.session.isStreaming)
1808
+ continue;
1809
+ let sessionFile;
1810
+ try {
1811
+ sessionFile = conv.session.sessionFile ?? undefined;
1812
+ }
1813
+ catch {
1814
+ sessionFile = undefined;
1815
+ }
1816
+ out.push({ title: conv.title, cwd: conv.cwd, sessionFile });
1793
1817
  }
1794
1818
  return out;
1795
1819
  }
1796
- /** Tell the user about runs lost to the last server restart (once). */
1797
- notifyInterrupted(list) {
1820
+ /** Reopen sessions interrupted by the last restart and continue them.
1821
+ *
1822
+ * For each record WITH a session file: reopen it (listed, keeps running
1823
+ * when displaced) and send a short Continue prompt so the run resumes
1824
+ * from the persisted context. Records WITHOUT a file fall back to the
1825
+ * warning notice. The conversation active before the resume is restored
1826
+ * at the end, so the user lands where they were and clicks whichever
1827
+ * resumed run they want — nothing steals the view.
1828
+ *
1829
+ * Called once on the first attach after a restart (fire-and-forget from
1830
+ * the attach path — each step is internally guarded and never throws). */
1831
+ async resumeInterrupted(list) {
1798
1832
  if (!list || list.length === 0)
1799
1833
  return;
1800
- const names = list.map((r) => `「${r.title}」(${r.cwd})`).join("、");
1801
- this.pendingNotices.push({
1802
- type: "notice",
1803
- level: "warning",
1804
- text: `上次服务重启时有 ${list.length} 个进行中的对话被中断:${names}。可在历史对话中恢复继续。`,
1805
- textEn: `${list.length} running conversation(s) were interrupted by the last restart: ${names}. Resume them from History.`,
1806
- });
1834
+ const resumable = list.filter((r) => r.sessionFile);
1835
+ const orphaned = list.filter((r) => !r.sessionFile);
1836
+ if (orphaned.length > 0) {
1837
+ const names = orphaned.map((r) => `「${r.title}」(${r.cwd})`).join("");
1838
+ this.emit({
1839
+ type: "notice",
1840
+ level: "warning",
1841
+ text: `上次服务重启时有 ${orphaned.length} 个进行中的对话被中断且无法自动恢复:${names}。可在历史对话中手动恢复继续。`,
1842
+ textEn: `${orphaned.length} running conversation(s) were interrupted by the last restart and could not be resumed automatically: ${names}. Resume them manually from History.`,
1843
+ });
1844
+ }
1845
+ if (resumable.length === 0)
1846
+ return;
1847
+ const continueText = this.getLang() === "zh" ? "继续" : "Continue";
1848
+ const restoreId = this.activeId;
1849
+ for (const r of resumable) {
1850
+ try {
1851
+ this.emit({
1852
+ type: "notice",
1853
+ level: "info",
1854
+ text: `上次服务重启中断了「${r.title}」,正在自动恢复并继续。`,
1855
+ textEn: `Restart interrupted "${r.title}" — reopening it and continuing automatically.`,
1856
+ });
1857
+ await this.switchSession(r.sessionFile);
1858
+ await this.prompt(continueText);
1859
+ }
1860
+ catch {
1861
+ // switchSession/prompt already surface failures as notices;
1862
+ // one bad session must not block the rest.
1863
+ }
1864
+ }
1865
+ // Hand the view back: the user lands where they were, resumed runs
1866
+ // keep going in the background list.
1867
+ try {
1868
+ if (restoreId && this.convs.has(restoreId) && restoreId !== this.activeId) {
1869
+ await this.switchConversation(restoreId);
1870
+ }
1871
+ }
1872
+ catch {
1873
+ /* staying on the last resumed session is a fine fallback */
1874
+ }
1807
1875
  }
1808
1876
  /** Add a socket to this client's broadcast set; flushes buffered startup notices. */
1809
1877
  attachSink(send) {
@@ -1838,6 +1906,7 @@ export class ClientSession {
1838
1906
  // Reconnect: push the built-in provider key list (multi-key grouping in the
1839
1907
  // model picker needs it even before the client asks).
1840
1908
  this.modelAdmin.listProviderKeys();
1909
+ this.modelAdmin.listProviderOAuthFlows();
1841
1910
  // PTYs are conversation-owned and survive a socket reconnect.
1842
1911
  this.pushTerminals();
1843
1912
  }
@@ -2027,6 +2096,153 @@ export class ClientSession {
2027
2096
  return null;
2028
2097
  }
2029
2098
  }
2099
+ /** 插件扩展点 v2(只读组装,供 index.ts 注入给 PluginManager 的 conversationLister)。
2100
+ * 本客户端运行中对话(kind:"running")+ 当前项目历史会话摘要(kind:"history",最多 50 条)。
2101
+ * 纯数据组装,不 emit、不改任何状态;历史会话读失败时只回运行中部分。 */
2102
+ listRunningForPlugins() {
2103
+ const out = [];
2104
+ for (const conv of this.convs.values()) {
2105
+ let isStreaming = false;
2106
+ try {
2107
+ isStreaming = conv.session.isStreaming;
2108
+ }
2109
+ catch {
2110
+ // 会话替换中——按未跑处理
2111
+ }
2112
+ out.push({ id: conv.id, title: conv.title, cwd: conv.cwd, kind: "running", isStreaming });
2113
+ }
2114
+ return out;
2115
+ }
2116
+ /** 插件扩展点 v2(conversationLister 的历史一半):当前项目历史会话摘要,最多 50 条。
2117
+ * 复用 refreshSessions/searchSessions 共用的 loadSessionInfos 缓存(3s TTL,不扫两遍盘)。 */
2118
+ async listHistoryForPlugins(limit = 50) {
2119
+ try {
2120
+ const infos = await this.loadSessionInfos();
2121
+ return infos.slice(0, Math.max(0, limit)).map((s) => ({
2122
+ id: s.path,
2123
+ title: (s.name?.trim() || s.firstMessage.trim() || basename(s.path)).slice(0, 60),
2124
+ cwd: this.cwd,
2125
+ kind: "history",
2126
+ isStreaming: false,
2127
+ }));
2128
+ }
2129
+ catch {
2130
+ return [];
2131
+ }
2132
+ }
2133
+ /** 插件扩展点 v2(供 conversationSearcher):运行中对话标题 + 历史会话全文匹配,返回前 N 个 {id,title}。
2134
+ * 复用 searchSessions 的 sessionMatchesSearch 判定(含转录全文),只读不 emit。 */
2135
+ async searchForPlugins(query, limit = 20) {
2136
+ const q = query.trim().toLowerCase();
2137
+ if (!q)
2138
+ return [];
2139
+ const out = [];
2140
+ for (const conv of this.convs.values()) {
2141
+ if (conv.title.toLowerCase().includes(q))
2142
+ out.push({ id: conv.id, title: conv.title });
2143
+ if (out.length >= limit)
2144
+ return out;
2145
+ }
2146
+ try {
2147
+ const infos = await this.loadSessionInfos();
2148
+ for (const s of infos) {
2149
+ if (!sessionMatchesSearch(q, s))
2150
+ continue;
2151
+ out.push({
2152
+ id: s.path,
2153
+ title: (s.name?.trim() || s.firstMessage.trim() || basename(s.path)).slice(0, 60),
2154
+ });
2155
+ if (out.length >= limit)
2156
+ break;
2157
+ }
2158
+ }
2159
+ catch {
2160
+ // 读盘失败只回运行中部分
2161
+ }
2162
+ return out;
2163
+ }
2164
+ /** 插件扩展点 v2(供 conversationWriter):向指定对话投递 prompt,复用现有 prompt 投递路径。
2165
+ * 目标非当前对话时先 switchConversation(复用跨项目切换副作用),再走 this.prompt
2166
+ * (斜杠拦截/排空门禁/并行提醒/首条命名全在里面);找不到对话返回 {ok:false,error}。 */
2167
+ async writeForPlugins(id, text) {
2168
+ try {
2169
+ const conv = this.convs.get(id);
2170
+ if (!conv)
2171
+ return { ok: false, error: `未知对话:${id}` };
2172
+ if (!text.trim())
2173
+ return { ok: false, error: "投递文本为空" };
2174
+ if (id !== this.activeId)
2175
+ await this.switchConversation(id);
2176
+ await this.prompt(text);
2177
+ return { ok: true };
2178
+ }
2179
+ catch (err) {
2180
+ return { ok: false, error: err.message };
2181
+ }
2182
+ }
2183
+ /** 插件扩展点 v2(供 runAborter):中止指定对话的运行,复用 abort 的 interruptRun 路径
2184
+ * (abort 卡住/空转时的强制重置语义一并继承)。未在跑时直接 {ok:true}(幂等)。 */
2185
+ async abortForPlugins(id) {
2186
+ try {
2187
+ const conv = this.convs.get(id);
2188
+ if (!conv)
2189
+ return { ok: false, error: `未知对话:${id}` };
2190
+ if (this.conversationStreaming(conv)) {
2191
+ await this.interruptRun(conv, "插件已中止运行");
2192
+ this.flushSnapshot();
2193
+ }
2194
+ return { ok: true };
2195
+ }
2196
+ catch (err) {
2197
+ return { ok: false, error: err.message };
2198
+ }
2199
+ }
2200
+ /** 插件扩展点(供 runSteerer 跨客户端兜底复用):只在本客户端 conversations 里找对话,
2201
+ * 持有则执行 steer 并返回结果,未持有回 undefined(不碰钩子,无递归)。 */
2202
+ async steerOwnConversation(id, text) {
2203
+ const conv = this.convs.get(id);
2204
+ if (!conv?.session)
2205
+ return undefined;
2206
+ await conv.session.sendUserMessage(text, conv.session.isStreaming ? { deliverAs: "steer" } : undefined);
2207
+ return { ok: true };
2208
+ }
2209
+ /** 插件扩展点(供 runSteerer):向指定对话插队一条用户消息,复用子代理 steer 的
2210
+ * sendUserMessage + deliverAs:'steer' 路径(运行时插队;未跑时按普通消息投递)。
2211
+ * 先找本客户端 conversations,找不到再经 steerConversationElsewhere 问其他客户端;
2212
+ * 空文本回 {ok:false};异常 catch 透传 message。 */
2213
+ async steerForPlugins(conversationId, text) {
2214
+ try {
2215
+ if (!text.trim())
2216
+ return { ok: false, error: "空消息" };
2217
+ const own = await this.steerOwnConversation(conversationId, text);
2218
+ if (own)
2219
+ return own;
2220
+ if (typeof this.steerConversationElsewhere === "function") {
2221
+ const r = await this.steerConversationElsewhere(conversationId, text);
2222
+ if (r)
2223
+ return r;
2224
+ }
2225
+ return { ok: false, error: `未知对话:${conversationId}` };
2226
+ }
2227
+ catch (err) {
2228
+ return { ok: false, error: err.message };
2229
+ }
2230
+ }
2231
+ /** 插件扩展点 v2(供 modelLister):复用 listModels 的模型列表映射 {id,provider,vision}。
2232
+ * 与 listModels 唯一差别:不做网络 refresh(插件列表走缓存目录,15s 超时也不等),只读不 emit。 */
2233
+ async listModelsForPlugins() {
2234
+ try {
2235
+ const available = await this.runtime.services.modelRuntime.getAvailable();
2236
+ return available.map((m) => ({
2237
+ id: `${m.provider}/${m.id}`,
2238
+ provider: m.provider,
2239
+ vision: m.input?.includes("image") ?? false,
2240
+ }));
2241
+ }
2242
+ catch {
2243
+ return [];
2244
+ }
2245
+ }
2030
2246
  onEvent(conv, event) {
2031
2247
  // Any SDK event proves the run is alive — feeds the stall watchdog below.
2032
2248
  conv.lastSdkEventAt = Date.now();
@@ -2535,7 +2751,7 @@ export class ClientSession {
2535
2751
  return messages;
2536
2752
  }
2537
2753
  /** Build every UiState field EXCEPT messages (the expensive part). */
2538
- buildLightState(rev) {
2754
+ buildLightState(rev, withDraft = false) {
2539
2755
  const conv = this.conv;
2540
2756
  const state = conv.session.agent.state;
2541
2757
  const model = state.model;
@@ -2587,6 +2803,9 @@ export class ClientSession {
2587
2803
  cwd: this.cwd,
2588
2804
  // 判重:直接读缓存字段,不在快照热路径上重读 client-state。
2589
2805
  workspaceRoots: this.roots,
2806
+ // 用户主目录(右栏 🏠):进程内不变,模块级求值一次,不在热路径调 homedir()。
2807
+ homeDir: HOME_WIRE,
2808
+ desktopDir: DESKTOP_WIRE,
2590
2809
  sessionId: this.session.sessionId,
2591
2810
  sessionFile: this.session.sessionFile,
2592
2811
  conversationId: this.activeId,
@@ -2610,6 +2829,10 @@ export class ClientSession {
2610
2829
  retry: conv.retryState ?? null,
2611
2830
  compaction: conv.compactionState ?? null,
2612
2831
  pendingQuestion: this.pendingQuestionForSnapshot(),
2832
+ // 未发送草稿只跟全量快照走(切会话/new_chat/get_state):增量 delta 里
2833
+ // 这个 key 必须整个缺席(不能是显式的 undefined——前端 delta 合并是
2834
+ // {...ui, ...d.state} 整批覆盖,显式 undefined 会把上次全量带回的草稿洗掉)。
2835
+ ...(withDraft ? { draft: this.draftForSnapshot() } : {}),
2613
2836
  tools: state.tools.map((t) => t.name),
2614
2837
  version: ++this.version,
2615
2838
  piConfigured: this.isPiConfigured(),
@@ -2653,7 +2876,7 @@ export class ClientSession {
2653
2876
  rev,
2654
2877
  baseRev,
2655
2878
  appended: cur.slice(prev.length),
2656
- state: this.buildLightState(rev),
2879
+ state: this.buildLightState(rev, false),
2657
2880
  });
2658
2881
  }
2659
2882
  else {
@@ -2662,7 +2885,9 @@ export class ClientSession {
2662
2885
  this.emittedRev = rev;
2663
2886
  this.emit({
2664
2887
  type: "snapshot",
2665
- state: { ...this.buildLightState(rev), messages: cur },
2888
+ // 全量快照一律带草稿(切会话/new_chat/改写分支/重连 get_state 全走这里);
2889
+ // 60ms 热帧是上面的 snapshot_delta,本来就不带。
2890
+ state: { ...this.buildLightState(rev, true), messages: cur },
2666
2891
  });
2667
2892
  }
2668
2893
  }
@@ -2981,6 +3206,9 @@ export class ClientSession {
2981
3206
  * - onRunningChanged:本实例流式集合变化时触发,AgentService 借此让其他
2982
3207
  * 客户端重推 conversations(elsewhere 列表近实时)。 */
2983
3208
  findSessionOwner = undefined;
3209
+ /** 插件 steer 跨客户端兜底钩子:attach 时由 AgentService 接线(见 steerElsewhere),
3210
+ * 在其他客户端的 conversations 里找对话并由持有方执行 steer,未持有回 undefined。 */
3211
+ steerConversationElsewhere = undefined;
2984
3212
  /** issue #145:除本客户端外是否有人在跑(扫目录查重前置的无 I/O 判断)。 */
2985
3213
  hasStreamingElsewhere = undefined;
2986
3214
  listProjectRunners = undefined;
@@ -3044,7 +3272,7 @@ export class ClientSession {
3044
3272
  }
3045
3273
  try {
3046
3274
  const targets = collectTargets(this.agentDir, ClientSession.currentAppVersion());
3047
- const items = await checkAllUpdates(targets, undefined, () => this.getLang(), resolveNpmRegistry(this.agentDir));
3275
+ const items = sortUpdateItems(await checkAllUpdates(targets, undefined, () => this.getLang(), resolveNpmRegistry(this.agentDir)));
3048
3276
  this.updatesAllCache = { at: Date.now(), items };
3049
3277
  this.emit({ type: "update_status_all", items });
3050
3278
  }
@@ -3201,10 +3429,27 @@ export class ClientSession {
3201
3429
  return this.modelAdmin.setProviderApiKey(provider, apiKey);
3202
3430
  }
3203
3431
  async clearProviderApiKey(provider) {
3432
+ const usingOAuth = this.runtime.services.modelRuntime.isUsingOAuth(provider.trim());
3204
3433
  await this.modelAdmin.clearProviderApiKey(provider);
3205
3434
  // The provider is back to unconfigured — drop its key preference in
3206
3435
  // EVERY project, otherwise each project switch re-tries a restore.
3207
- this.stateStore.deleteProviderEverywhere(provider.trim());
3436
+ if (!usingOAuth)
3437
+ this.stateStore.deleteProviderEverywhere(provider.trim());
3438
+ }
3439
+ startProviderOAuth(provider) {
3440
+ this.modelAdmin.startProviderOAuth(provider);
3441
+ }
3442
+ replyProviderOAuth(flowId, promptId, value) {
3443
+ this.modelAdmin.replyProviderOAuth(flowId, promptId, value);
3444
+ }
3445
+ cancelProviderOAuth(flowId) {
3446
+ this.modelAdmin.cancelProviderOAuth(flowId);
3447
+ }
3448
+ listProviderOAuthFlows() {
3449
+ this.modelAdmin.listProviderOAuthFlows();
3450
+ }
3451
+ logoutProviderOAuth(provider) {
3452
+ return this.modelAdmin.logoutProviderOAuth(provider);
3208
3453
  }
3209
3454
  listProviders() {
3210
3455
  return this.modelAdmin.listProviders();
@@ -3634,6 +3879,14 @@ export class ClientSession {
3634
3879
  // switch/new_chat while prompt() is in flight must never target a
3635
3880
  // different conversation.
3636
3881
  const conv = this.conv;
3882
+ // 输入框内容被消费(发送/斜杠执行)→ 清掉该会话存过的草稿(best-effort)。
3883
+ // 快捷短语发送(不碰输入框)同样清:客户端发送成功后会把当前草稿重存回来。
3884
+ try {
3885
+ this.drafts.clear(conv.session.sessionId);
3886
+ }
3887
+ catch {
3888
+ // ignore
3889
+ }
3637
3890
  try {
3638
3891
  const s = this.session;
3639
3892
  // Native slash commands (see NATIVE_COMMANDS) are executed here and
@@ -3954,6 +4207,32 @@ export class ClientSession {
3954
4207
  }
3955
4208
  this.flushSnapshot();
3956
4209
  }
4210
+ // -----------------------------------------------------------------------
4211
+ // 未发送输入框草稿(issue #166,单中心文件方案,见 server/composer-drafts.ts)
4212
+ // -----------------------------------------------------------------------
4213
+ /** 存指定会话的未发送草稿(`draft_update` 入口,经 DispatchSession.saveDraft)。
4214
+ * 按消息自带的 sessionId 落键(不按 active 会话:切会话时的「离开刷盘」
4215
+ * 晚于服务端的切换到达)。空白新会话的转录还没落盘,但 id 内存里已有。
4216
+ * 存完不推快照:同页的草稿本来就是自己打的;恢复走全量快照的 draft 字段。 */
4217
+ saveDraft(sessionId, text, ts) {
4218
+ try {
4219
+ if (!sessionId)
4220
+ return;
4221
+ this.drafts.save(sessionId, text, ts);
4222
+ }
4223
+ catch {
4224
+ // best-effort:草稿丢了可以重打
4225
+ }
4226
+ }
4227
+ /** 当前活跃对话的草稿(全量快照用;增量 snapshot_delta 传 withDraft=false 不带)。 */
4228
+ draftForSnapshot() {
4229
+ try {
4230
+ return this.drafts.get(this.conv.session.sessionId) ?? null;
4231
+ }
4232
+ catch {
4233
+ return null;
4234
+ }
4235
+ }
3957
4236
  /** Re-push the current list on request (panel opened); prunes dead entries first. */
3958
4237
  async listBgServers() {
3959
4238
  await this.bg.listAndPush();
@@ -4117,7 +4396,8 @@ export class ClientSession {
4117
4396
  * 空白新对话」——/new <prompt> 只在 true 时投递首条提示;false 表示没能进入
4118
4397
  * 新对话(准入关闭 / 同项目对话数达上限 / runtime 创建失败),此时照发会把
4119
4398
  * 首条提示投进用户原本正在用的那个对话里。 */
4120
- async newChat() {
4399
+ async newChat(_preset) {
4400
+ // _preset: DSH Agent 预设(pi 引擎无此概念,忽略;wire 统一见 protocol new_chat)。
4121
4401
  if (this.quiesceBlocked())
4122
4402
  return false;
4123
4403
  // Reuse an already-open blank conversation instead of piling up new ones
@@ -4687,6 +4967,13 @@ export class ClientSession {
4687
4967
  }
4688
4968
  }
4689
4969
  rmSync(abs, { force: true });
4970
+ // 转录删了,未发送草稿再留着就是孤儿,一起清掉。
4971
+ try {
4972
+ this.drafts.pruneSessionFile(abs);
4973
+ }
4974
+ catch {
4975
+ // ignore
4976
+ }
4690
4977
  // Bust the brief session-info fridge: refreshSessions() below usually
4691
4978
  // lands inside its 3s TTL and would otherwise re-serve a listing that
4692
4979
  // still contains the deleted transcript.
@@ -5506,6 +5793,22 @@ export class ClientSession {
5506
5793
  async uploadFile(relDir, name, data) {
5507
5794
  return this.files.uploadFile(relDir, name, data);
5508
5795
  }
5796
+ /** 文件树右键菜单:新建(空文件/空文件夹)。 */
5797
+ async createEntry(dir, name, kind) {
5798
+ return this.files.createEntry(dir, name, kind);
5799
+ }
5800
+ /** 文件树右键菜单:同目录内重命名。 */
5801
+ async renameEntry(path, newName) {
5802
+ return this.files.renameEntry(path, newName);
5803
+ }
5804
+ /** 文件树右键菜单:删除文件/目录。 */
5805
+ async deleteEntry(path) {
5806
+ return this.files.deleteEntry(path);
5807
+ }
5808
+ /** 文件树右键菜单:复制/移动(move=true 即剪切粘贴)。 */
5809
+ async copyEntry(src, destDir, move) {
5810
+ return this.files.copyEntry(src, destDir, move);
5811
+ }
5509
5812
  async makeDir(relPath) {
5510
5813
  return this.files.makeDir(relPath);
5511
5814
  }
@@ -5862,6 +6165,7 @@ export class ClientSession {
5862
6165
  }
5863
6166
  async dispose() {
5864
6167
  this.disposed = true;
6168
+ this.modelAdmin.dispose();
5865
6169
  for (const conv of this.convs.values())
5866
6170
  conv.terminals.killAll();
5867
6171
  if (this.snapshotTimer) {
@@ -5991,6 +6295,24 @@ export class AgentService {
5991
6295
  }
5992
6296
  return null;
5993
6297
  }
6298
+ /** 插件 steer 跨客户端兜底:除请求方外逐个问其他客户端的 conversations,
6299
+ * 找到持有方由其执行 steer(只调 steerOwnConversation,不碰钩子,无递归);
6300
+ * 都找不到回 undefined,调用方回未知对话。单客户端异常跳过,不影响其他。 */
6301
+ async steerElsewhere(excludeClientId, id, text) {
6302
+ for (const [clientId, cs] of this.clients) {
6303
+ if (clientId === excludeClientId)
6304
+ continue;
6305
+ try {
6306
+ const r = await cs.steerOwnConversation(id, text);
6307
+ if (r)
6308
+ return r;
6309
+ }
6310
+ catch {
6311
+ // 单客户端坏了继续找下一个
6312
+ }
6313
+ }
6314
+ return undefined;
6315
+ }
5994
6316
  /** issue #145:别处在某 cwd 下正在跑的对话(同项目并行感知用,不含请求方)。 */
5995
6317
  listProjectRunners(cwd, excludeClientId) {
5996
6318
  const out = [];
@@ -6169,6 +6491,7 @@ export class AgentService {
6169
6491
  cs.listExternalRunning = () => this.listExternalRunning(clientId);
6170
6492
  cs.notifyExternalClients = (msg) => this.notifyClientsExcept(clientId, msg);
6171
6493
  cs.onRunningChanged = () => this.pokeExternalRunning(clientId);
6494
+ cs.steerConversationElsewhere = (id, text) => this.steerElsewhere(clientId, id, text);
6172
6495
  // Make sure the restored/default workspace appears in the project list.
6173
6496
  this.stateStore.remember(clientId, cwd);
6174
6497
  if (cwd !== this.cwd) {
@@ -6181,10 +6504,11 @@ export class AgentService {
6181
6504
  }
6182
6505
  }
6183
6506
  }
6184
- // First attach after a restart: report runs that were interrupted when
6185
- // the previous process shut down (consumed once, then cleared). Queue
6186
- // BEFORE attachSink so the notice rides the initial pending-notice flush.
6187
- cs.notifyInterrupted(this.stateStore.takeInterrupted(clientId));
6507
+ // First attach after a restart: reopen sessions that were streaming
6508
+ // when the previous process shut down and continue them (consumed
6509
+ // once, then cleared). Fire-and-forget AFTER attachSink + hooks:
6510
+ // resume emits directly to sinks and needs the owner guards.
6511
+ // Progress arrives over the socket as usual.
6188
6512
  cs.attachSink(send);
6189
6513
  // Forward hooks (set once by index.ts) to every session.
6190
6514
  cs.onQuit = this.onQuit;
@@ -6203,10 +6527,12 @@ export class AgentService {
6203
6527
  cs.listExternalRunning = () => this.listExternalRunning(clientId);
6204
6528
  cs.notifyExternalClients = (msg) => this.notifyClientsExcept(clientId, msg);
6205
6529
  cs.onRunningChanged = () => this.pokeExternalRunning(clientId);
6530
+ cs.steerConversationElsewhere = (id, text) => this.steerElsewhere(clientId, id, text);
6206
6531
  // 插件宿主工作区跟随:初次接入也同步一次(恢复的 lastCwd 可能≠服务启动目录),
6207
6532
  // notifyCwd 幂等去重;此后 set_cwd 成功时由 cs.onCwdChanged 继续驱动。
6208
6533
  cs.onCwdChanged = (abs, roots) => this.onClientCwdChanged?.(abs, roots);
6209
6534
  this.onClientCwdChanged?.(cs.cwd, cs.workspaceRoots);
6535
+ void cs.resumeInterrupted(this.stateStore.takeInterrupted(clientId));
6210
6536
  return cs;
6211
6537
  }
6212
6538
  /** 插件 AI 工具集合变化(注册/注销)时由 index.ts 触发:推送到所有客户端的全部会话。 */
@@ -6250,7 +6576,30 @@ export class AgentService {
6250
6576
  get(clientId) {
6251
6577
  return this.clients.get(clientId);
6252
6578
  }
6253
- async disposeAll() {
6579
+ /** 插件扩展点 v2:挑一个最合适的客户端会话供无浏览器调用的插件 API 用
6580
+ * (conversationLister/Searcher/Writer、modelLister、runAborter)。
6581
+ * 有运行中对话的优先,否则任意残留客户端;一个没有时返回 undefined,
6582
+ * 调用方(index.ts 注入)回退空列表 / {ok:false},绝不抛错。 */
6583
+ pluginClient() {
6584
+ let fallback;
6585
+ for (const cs of this.clients.values()) {
6586
+ if (!fallback)
6587
+ fallback = cs;
6588
+ try {
6589
+ if (cs.activeConversations() > 0)
6590
+ return cs;
6591
+ }
6592
+ catch {
6593
+ // 单客户端坏了不影响挑选
6594
+ }
6595
+ }
6596
+ return fallback;
6597
+ }
6598
+ /** Snapshot still-streaming conversations for post-restart resume.
6599
+ * Called during graceful shutdown AND from the restart_service handler
6600
+ * (which exits without shutdown under systemd — without this, the
6601
+ * interrupted-run record would silently never be written there). */
6602
+ recordInterruptedRuns() {
6254
6603
  // Record still-streaming conversations BEFORE tearing anything down, so
6255
6604
  // the next attach can tell the user what was lost (SIGTERM / update).
6256
6605
  // eslint-disable-next-line unicorn/no-useless-spread -- snapshot: handlers may unsubscribe mid-emit
@@ -6265,6 +6614,9 @@ export class AgentService {
6265
6614
  // best effort — never block shutdown on bookkeeping
6266
6615
  }
6267
6616
  }
6617
+ }
6618
+ async disposeAll() {
6619
+ this.recordInterruptedRuns();
6268
6620
  const all = [...this.clients.values()];
6269
6621
  this.clients.clear();
6270
6622
  await Promise.all(all.map((cs) => cs.dispose()));
@@ -357,6 +357,12 @@ export class ClientStateStore {
357
357
  reviewDisabledSkills: stored?.reviewDisabledSkills ?? [],
358
358
  disabledPlugins: stored?.disabledPlugins ?? [],
359
359
  uiLayout: normalizeUiLayout(stored?.uiLayout),
360
+ defaultAgentPreset: typeof stored?.defaultAgentPreset === "string" && stored.defaultAgentPreset
361
+ ? stored.defaultAgentPreset
362
+ : "standard",
363
+ defaultPermissionPreset: typeof stored?.defaultPermissionPreset === "string" && stored.defaultPermissionPreset
364
+ ? stored.defaultPermissionPreset
365
+ : "workspace-write-never",
360
366
  };
361
367
  }
362
368
  /** Persist the settings-panel state (partial merge) — global shared config. */
@@ -395,6 +401,8 @@ export class ClientStateStore {
395
401
  uiLayout: normalizeUiLayout(settings.uiLayout ?? cur.uiLayout),
396
402
  quickPhrases: settings.quickPhrases ?? cur.quickPhrases ?? [],
397
403
  quickPhrasesEnabled: settings.quickPhrasesEnabled ?? cur.quickPhrasesEnabled ?? true,
404
+ defaultAgentPreset: settings.defaultAgentPreset ?? cur.defaultAgentPreset ?? "standard",
405
+ defaultPermissionPreset: settings.defaultPermissionPreset ?? cur.defaultPermissionPreset ?? "workspace-write-never",
398
406
  };
399
407
  this.save();
400
408
  }